From fc9e52c49751aea9645f916d8cd8683a90f37ccc Mon Sep 17 00:00:00 2001 From: MinhxNguyen7 Date: Tue, 22 Aug 2023 12:56:27 +0700 Subject: [PATCH 01/89] Add instructor to queryGrades --- apps/antalmanac/src/lib/helpers.ts | 48 +++++++++++++++++++----------- 1 file changed, 31 insertions(+), 17 deletions(-) diff --git a/apps/antalmanac/src/lib/helpers.ts b/apps/antalmanac/src/lib/helpers.ts index 5204bba73..6da1a6533 100644 --- a/apps/antalmanac/src/lib/helpers.ts +++ b/apps/antalmanac/src/lib/helpers.ts @@ -231,30 +231,44 @@ export interface Grades { const gradesCache: { [key: string]: Grades } = {}; -export async function queryGrades(deptCode: string, courseNumber: string) { - if (gradesCache[deptCode + courseNumber]) { - return gradesCache[deptCode + courseNumber]; +/* + * Query the PeterPortal GraphQL API for a course's grades with caching + * + * @param deptCode The department code of the course. + * @param courseNumber The course number of the course. + * @param instructor The instructor's name (optional) + * + * @returns Grades + */ +export async function queryGrades(deptCode: string, courseNumber: string, instructor = '') { + const cacheKey = deptCode + courseNumber; + + if (gradesCache[cacheKey]) { + return gradesCache[cacheKey]; } - const queryString = ` - { aggregateGrades(department: "${deptCode}", courseNumber: "${courseNumber}", ) { - gradeDistribution { - gradeACount - gradeBCount - gradeCCount - gradeDCount - gradeFCount - gradePCount - gradeNPCount - averageGPA - } - }, + instructor = instructor.replace('STAFF', '').trim(); // Ignore STAFF + const instructorFilter = instructor ? `instructor: "${instructor}"` : ''; + + const queryString = `{ + aggregateGrades(department: "${deptCode}", courseNumber: "${courseNumber}", ${instructorFilter}) { + gradeDistribution { + gradeACount + gradeBCount + gradeCCount + gradeDCount + gradeFCount + gradePCount + gradeNPCount + averageGPA + } + }, }`; const resp = await queryGraphQL(queryString); const grades = resp.data.aggregateGrades.gradeDistribution; - gradesCache[deptCode + courseNumber] = grades; + gradesCache[cacheKey] = grades; return grades; } From c9ccbbd6f89cebd029852a04ed0077de9ddcd33f Mon Sep 17 00:00:00 2001 From: MinhxNguyen7 Date: Wed, 23 Aug 2023 10:20:34 +0700 Subject: [PATCH 02/89] GPA cell in section table --- .../components/RightPane/RightPaneStore.ts | 1 + .../RightPane/SectionTable/SectionTable.tsx | 1 + .../SectionTable/SectionTableBody.tsx | 40 ++++++++++++++++++- 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/apps/antalmanac/src/components/RightPane/RightPaneStore.ts b/apps/antalmanac/src/components/RightPane/RightPaneStore.ts index 07c53423d..098455045 100644 --- a/apps/antalmanac/src/components/RightPane/RightPaneStore.ts +++ b/apps/antalmanac/src/components/RightPane/RightPaneStore.ts @@ -14,6 +14,7 @@ export const SECTION_TABLE_COLUMNS = [ 'sectionCode', 'sectionDetails', 'instructors', + 'gpa', 'dayAndTime', 'location', 'sectionEnrollment', diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTable.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTable.tsx index 3884c97c8..985963231 100644 --- a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTable.tsx +++ b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTable.tsx @@ -74,6 +74,7 @@ const tableHeaderColumns: Record = { sectionCode: 'Code', sectionDetails: 'Type', instructors: 'Instructors', + gpa: 'GPA', dayAndTime: 'Times', location: 'Places', sectionEnrollment: 'Enrollment', diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx index f931b351c..a1c6823d7 100644 --- a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx +++ b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx @@ -15,7 +15,7 @@ import { OpenSpotAlertPopoverProps } from './OpenSpotAlertPopover'; import { ColorAndDelete, ScheduleAddCell } from './SectionTableButtons'; import restrictionsMapping from './static/restrictionsMapping.json'; import analyticsEnum, { logAnalytics } from '$lib/analytics'; -import { clickToCopy, CourseDetails, isDarkMode } from '$lib/helpers'; +import { clickToCopy, CourseDetails, isDarkMode, queryGrades } from '$lib/helpers'; import AppStore from '$stores/AppStore'; import { mobileContext } from '$components/MobileHome'; import locationIds from '$lib/location_ids'; @@ -182,6 +182,41 @@ const InstructorsCell = withStyles(styles)((props: InstructorsCellProps) => { return {getLinks(instructors)}; }); +interface GPACellProps { + classes: ClassNameMap; + deptCode: string; + courseNumber: string; + instructors: string[]; +} + +const GPACell = withStyles(styles)((props: GPACellProps) => { + const { classes, deptCode, courseNumber, instructors } = props; + + const [gpa, setGpa] = useState(''); + + useEffect(() => { + const loadGpa = async (deptCode: string, courseNumber: string, instructors: string[]) => { + // Get the GPA of the first instructor of this section where data exists + for (const instructor of instructors.filter((instructor) => instructor !== 'STAFF')) { + const grades = await queryGrades(deptCode, courseNumber, instructor); + + if (grades.averageGPA) { + setGpa(grades.averageGPA.toFixed(2).toString()); + return; + } + } + }; + + loadGpa(deptCode, courseNumber, instructors).catch(console.log); + }, [deptCode, courseNumber, instructors]); + + return ( + + {gpa} + + ); +}); + interface LocationsCellProps { classes: ClassNameMap; meetings: WebsocSectionMeeting[]; @@ -354,6 +389,7 @@ const tableBodyCells: Record> = { sectionCode: CourseCodeCell, sectionDetails: SectionDetailsCell, instructors: InstructorsCell, + gpa: GPACell, dayAndTime: DayAndTimeCell, location: LocationsCell, sectionEnrollment: SectionEnrollmentCell, @@ -367,7 +403,7 @@ const tableBodyCells: Record> = { const SectionTableBody = withStyles(styles)((props: SectionTableBodyProps) => { const { classes, section, courseDetails, term, allowHighlight, scheduleNames } = props; - const [activeColumns, setColumns] = useState(RightPaneStore.getActiveColumns()); + const [activeColumns, setColumns] = useState(RightPaneStore.getActiveColumns()); const [addedCourse, setAddedCourse] = useState( AppStore.getAddedSectionCodes().has(`${section.sectionCode} ${term}`) From 800514bda8a9b70f67685ceabcb2fd3f53d8914a Mon Sep 17 00:00:00 2001 From: MinhxNguyen7 Date: Wed, 23 Aug 2023 10:36:42 +0700 Subject: [PATCH 03/89] Reasonable desktop styling --- .../RightPane/SectionTable/SectionTable.tsx | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTable.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTable.tsx index 985963231..fe49ed783 100644 --- a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTable.tsx +++ b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTable.tsx @@ -36,6 +36,7 @@ const styles = { padding: '0px 0px 0px 0px', }, row: { + // TODO: Style this for mobile, possibly with minWidth pixels with side scrolling '&:nth-child(1)': { width: '8%', }, @@ -46,23 +47,26 @@ const styles = { width: '8%', }, '&:nth-child(4)': { - width: '15%', + width: '13%', }, '&:nth-child(5)': { - width: '12%', + width: '5%', }, '&:nth-child(6)': { - width: '10%', + width: '15%', }, '&:nth-child(7)': { - width: '10%', + width: '8%', }, '&:nth-child(8)': { - width: '8%', + width: '10%', }, '&:nth-child(9)': { width: '8%', }, + '&:nth-child(10)': { + width: '8%', + }, }, container: {}, titleRow: {}, From e4b021c245e9e9a2c01082e25d8eda0f642183cd Mon Sep 17 00:00:00 2001 From: MinhxNguyen7 Date: Wed, 23 Aug 2023 10:38:47 +0700 Subject: [PATCH 04/89] Button to hide GPA column --- .../src/components/RightPane/CoursePane/CoursePaneButtonRow.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/antalmanac/src/components/RightPane/CoursePane/CoursePaneButtonRow.tsx b/apps/antalmanac/src/components/RightPane/CoursePane/CoursePaneButtonRow.tsx index 4ec8e3636..e717ada14 100644 --- a/apps/antalmanac/src/components/RightPane/CoursePane/CoursePaneButtonRow.tsx +++ b/apps/antalmanac/src/components/RightPane/CoursePane/CoursePaneButtonRow.tsx @@ -33,6 +33,7 @@ const columnLabels: Record = { sectionCode: 'Code', sectionDetails: 'Type', instructors: 'Instructors', + gpa: 'GPA', dayAndTime: 'Times', location: 'Places', sectionEnrollment: 'Enrollment', From efd2f90a3791d0b41bab3d942b87b37c2e0c3dc2 Mon Sep 17 00:00:00 2001 From: MinhxNguyen7 Date: Wed, 23 Aug 2023 11:33:51 +0700 Subject: [PATCH 05/89] Basic grades popup functionality --- .../SectionTable/SectionTableBody.tsx | 47 ++++++++++++++++++- 1 file changed, 45 insertions(+), 2 deletions(-) diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx index a1c6823d7..272f2e74f 100644 --- a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx +++ b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx @@ -1,4 +1,15 @@ -import { Box, Chip, Popover, TableCell, TableRow, Theme, Tooltip, Typography, useMediaQuery } from '@material-ui/core'; +import { + Box, + Button, + Chip, + Popover, + TableCell, + TableRow, + Theme, + Tooltip, + Typography, + useMediaQuery, +} from '@material-ui/core'; import { Link } from 'react-router-dom'; import { withStyles } from '@material-ui/core/styles'; import { ClassNameMap, Styles } from '@material-ui/core/styles/withStyles'; @@ -14,6 +25,7 @@ import { MOBILE_BREAKPOINT } from '../../../globals'; import { OpenSpotAlertPopoverProps } from './OpenSpotAlertPopover'; import { ColorAndDelete, ScheduleAddCell } from './SectionTableButtons'; import restrictionsMapping from './static/restrictionsMapping.json'; +import GradesPopup from './GradesPopup'; import analyticsEnum, { logAnalytics } from '$lib/analytics'; import { clickToCopy, CourseDetails, isDarkMode, queryGrades } from '$lib/helpers'; import AppStore from '$stores/AppStore'; @@ -193,6 +205,7 @@ const GPACell = withStyles(styles)((props: GPACellProps) => { const { classes, deptCode, courseNumber, instructors } = props; const [gpa, setGpa] = useState(''); + const [instructor, setInstructor] = useState(''); useEffect(() => { const loadGpa = async (deptCode: string, courseNumber: string, instructors: string[]) => { @@ -202,6 +215,7 @@ const GPACell = withStyles(styles)((props: GPACellProps) => { if (grades.averageGPA) { setGpa(grades.averageGPA.toFixed(2).toString()); + setInstructor(instructor); return; } } @@ -210,9 +224,38 @@ const GPACell = withStyles(styles)((props: GPACellProps) => { loadGpa(deptCode, courseNumber, instructors).catch(console.log); }, [deptCode, courseNumber, instructors]); + const [anchorEl, setAnchorEl] = useState(null); + + const showDistribution = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + + const hideDistribution = () => { + setAnchorEl(null); + }; + return ( + // TODO: Pass instructor to GradesPopup for Zotistics link - {gpa} + + {gpa} + + + + ); }); From f91d405bf430b1d1433a7396a722eddca9c5384a Mon Sep 17 00:00:00 2001 From: MinhxNguyen7 Date: Wed, 23 Aug 2023 15:14:29 +0700 Subject: [PATCH 06/89] Close distribution on clickaway --- .../components/RightPane/SectionTable/SectionTableBody.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx index 272f2e74f..486823bfc 100644 --- a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx +++ b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx @@ -2,6 +2,7 @@ import { Box, Button, Chip, + ClickAwayListener, Popover, TableCell, TableRow, @@ -236,9 +237,12 @@ const GPACell = withStyles(styles)((props: GPACellProps) => { return ( // TODO: Pass instructor to GradesPopup for Zotistics link + // I don't know why the popover doesn't close on clickaway without the listener, but this does seem to be the usual recommendation - {gpa} + + {gpa} + Date: Wed, 23 Aug 2023 15:27:03 +0700 Subject: [PATCH 07/89] Styling for GPA text --- .../RightPane/SectionTable/SectionTableBody.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx index 486823bfc..135aa6c8e 100644 --- a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx +++ b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx @@ -61,7 +61,7 @@ const styles: Styles = (theme) => ({ }, }, cell: { - fontSize: '0.85rem', + fontSize: '0.85rem !important', }, link: { textDecoration: 'underline', @@ -101,6 +101,10 @@ const styles: Styles = (theme) => ({ Stu: { color: '#179523' }, Tap: { color: '#8d2df0' }, Tut: { color: '#ffc705' }, + popoverText: { + color: isDarkMode() ? 'dodgerblue' : 'blue', + cursor: 'pointer', + }, }); const NoPaddingTableCell = withStyles({ @@ -239,9 +243,11 @@ const GPACell = withStyles(styles)((props: GPACellProps) => { // TODO: Pass instructor to GradesPopup for Zotistics link // I don't know why the popover doesn't close on clickaway without the listener, but this does seem to be the usual recommendation - + - {gpa} + + {gpa} + Date: Wed, 23 Aug 2023 15:41:57 +0700 Subject: [PATCH 08/89] Hide Zotistics link in GPA popup --- .../RightPane/SectionTable/GradesPopup.tsx | 23 +++++++++++-------- .../SectionTable/SectionTableBody.tsx | 1 + 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/GradesPopup.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/GradesPopup.tsx index 1a3bf40e7..3b156a051 100644 --- a/apps/antalmanac/src/components/RightPane/SectionTable/GradesPopup.tsx +++ b/apps/antalmanac/src/components/RightPane/SectionTable/GradesPopup.tsx @@ -30,6 +30,7 @@ interface GradesPopupProps { courseNumber: string; classes: ClassNameMap; isMobileScreen: boolean; + showLink?: boolean; } interface GradeData { @@ -37,7 +38,7 @@ interface GradeData { all: number; } -const GradesPopup = ({ deptCode, courseNumber, classes, isMobileScreen }: GradesPopupProps) => { +const GradesPopup = ({ deptCode, courseNumber, classes, isMobileScreen, showLink = true }: GradesPopupProps) => { const [loading, setLoading] = useState(true); const [graphTitle, setGraphTitle] = useState(null); const [gradeData, setGradeData] = useState(null); @@ -99,15 +100,17 @@ const GradesPopup = ({ deptCode, courseNumber, classes, isMobileScreen }: Grades )} - + {showLink && ( + + )} ); } diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx index 135aa6c8e..0f8e73e90 100644 --- a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx +++ b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx @@ -264,6 +264,7 @@ const GPACell = withStyles(styles)((props: GPACellProps) => { courseNumber={courseNumber} classes={classes} isMobileScreen={useMediaQuery(`(max-width: ${MOBILE_BREAKPOINT}`)} + showLink={false} /> From e673331d065a258c9738070554346cd708e711f0 Mon Sep 17 00:00:00 2001 From: MinhxNguyen7 Date: Wed, 23 Aug 2023 15:46:29 +0700 Subject: [PATCH 09/89] Close GPA distribution on re-click --- .../components/RightPane/SectionTable/SectionTableBody.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx index 0f8e73e90..f876af53f 100644 --- a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx +++ b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx @@ -231,8 +231,8 @@ const GPACell = withStyles(styles)((props: GPACellProps) => { const [anchorEl, setAnchorEl] = useState(null); - const showDistribution = (event: React.MouseEvent) => { - setAnchorEl(event.currentTarget); + const handleClick = (event: React.MouseEvent) => { + setAnchorEl(anchorEl ? null : event.currentTarget); }; const hideDistribution = () => { @@ -245,7 +245,7 @@ const GPACell = withStyles(styles)((props: GPACellProps) => { - + {gpa} From ef39739129c44001970578ed0414eba5e4a3b356 Mon Sep 17 00:00:00 2001 From: MinhxNguyen7 Date: Wed, 23 Aug 2023 16:03:41 +0700 Subject: [PATCH 10/89] Add instructor to grade cache key --- apps/antalmanac/src/lib/helpers.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/antalmanac/src/lib/helpers.ts b/apps/antalmanac/src/lib/helpers.ts index 6da1a6533..25ea77d23 100644 --- a/apps/antalmanac/src/lib/helpers.ts +++ b/apps/antalmanac/src/lib/helpers.ts @@ -241,15 +241,15 @@ const gradesCache: { [key: string]: Grades } = {}; * @returns Grades */ export async function queryGrades(deptCode: string, courseNumber: string, instructor = '') { - const cacheKey = deptCode + courseNumber; + instructor = instructor.replace('STAFF', '').trim(); // Ignore STAFF + const instructorFilter = instructor ? `instructor: "${instructor}"` : ''; + + const cacheKey = deptCode + courseNumber + instructor; if (gradesCache[cacheKey]) { return gradesCache[cacheKey]; } - instructor = instructor.replace('STAFF', '').trim(); // Ignore STAFF - const instructorFilter = instructor ? `instructor: "${instructor}"` : ''; - const queryString = `{ aggregateGrades(department: "${deptCode}", courseNumber: "${courseNumber}", ${instructorFilter}) { gradeDistribution { From f3a5bf3130f2cdcba1ada29ea409fd574944caaa Mon Sep 17 00:00:00 2001 From: MinhxNguyen7 Date: Wed, 23 Aug 2023 16:07:51 +0700 Subject: [PATCH 11/89] Section GPA graph for particular instructor --- .../RightPane/SectionTable/GradesPopup.tsx | 12 ++++++++++-- .../RightPane/SectionTable/SectionTableBody.tsx | 1 + 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/GradesPopup.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/GradesPopup.tsx index 3b156a051..af7ea4cc4 100644 --- a/apps/antalmanac/src/components/RightPane/SectionTable/GradesPopup.tsx +++ b/apps/antalmanac/src/components/RightPane/SectionTable/GradesPopup.tsx @@ -28,6 +28,7 @@ const styles: Styles = { interface GradesPopupProps { deptCode: string; courseNumber: string; + instructor?: string; classes: ClassNameMap; isMobileScreen: boolean; showLink?: boolean; @@ -38,7 +39,14 @@ interface GradeData { all: number; } -const GradesPopup = ({ deptCode, courseNumber, classes, isMobileScreen, showLink = true }: GradesPopupProps) => { +const GradesPopup = ({ + deptCode, + courseNumber, + instructor = '', + classes, + isMobileScreen, + showLink = true, +}: GradesPopupProps) => { const [loading, setLoading] = useState(true); const [graphTitle, setGraphTitle] = useState(null); const [gradeData, setGradeData] = useState(null); @@ -49,7 +57,7 @@ const GradesPopup = ({ deptCode, courseNumber, classes, isMobileScreen, showLink } try { - const courseGrades = await queryGrades(deptCode, courseNumber); + const courseGrades = await queryGrades(deptCode, courseNumber, instructor); const data = []; for (const [key, value] of Object.entries(courseGrades)) { diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx index f876af53f..2a3e17dcf 100644 --- a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx +++ b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx @@ -262,6 +262,7 @@ const GPACell = withStyles(styles)((props: GPACellProps) => { Date: Wed, 23 Aug 2023 16:11:51 +0700 Subject: [PATCH 12/89] Restore labels to GPA graph --- .../src/components/RightPane/SectionTable/GradesPopup.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/GradesPopup.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/GradesPopup.tsx index af7ea4cc4..635312abb 100644 --- a/apps/antalmanac/src/components/RightPane/SectionTable/GradesPopup.tsx +++ b/apps/antalmanac/src/components/RightPane/SectionTable/GradesPopup.tsx @@ -64,7 +64,7 @@ const GradesPopup = ({ // format data for display in chart // key formatting: sum_grade_a_count -> A if (key !== 'averageGPA') { - data.push({ name: key.split('_')[2]?.toUpperCase(), all: value as number }); + data.push({ name: key.replace('grade', '').replace('Count', ''), all: value as number }); } } From b28cd8aea4fe1fa326a07fd6ee8e68daed1f697c Mon Sep 17 00:00:00 2001 From: MinhxNguyen7 Date: Wed, 23 Aug 2023 20:21:16 +0700 Subject: [PATCH 13/89] Did todo --- .../src/components/RightPane/SectionTable/SectionTableBody.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx index 2a3e17dcf..7ed9827e2 100644 --- a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx +++ b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx @@ -240,7 +240,6 @@ const GPACell = withStyles(styles)((props: GPACellProps) => { }; return ( - // TODO: Pass instructor to GradesPopup for Zotistics link // I don't know why the popover doesn't close on clickaway without the listener, but this does seem to be the usual recommendation From ead9dadb0fac16c4d0b6ae74bd445499cf06138d Mon Sep 17 00:00:00 2001 From: MinhxNguyen7 Date: Sat, 26 Aug 2023 14:40:36 +0700 Subject: [PATCH 14/89] Replace Zotistics link with anchor on graph --- .../RightPane/SectionTable/GradesPopup.tsx | 52 ++++++++----------- .../SectionTable/SectionTableBody.tsx | 2 - 2 files changed, 22 insertions(+), 32 deletions(-) diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/GradesPopup.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/GradesPopup.tsx index 635312abb..b6c0936a5 100644 --- a/apps/antalmanac/src/components/RightPane/SectionTable/GradesPopup.tsx +++ b/apps/antalmanac/src/components/RightPane/SectionTable/GradesPopup.tsx @@ -23,6 +23,10 @@ const styles: Styles = { skeleton: { padding: '4px', }, + graphAnchor: { + cursor: 'pointer', + overflow: 'hidden', + }, }; interface GradesPopupProps { @@ -31,7 +35,6 @@ interface GradesPopupProps { instructor?: string; classes: ClassNameMap; isMobileScreen: boolean; - showLink?: boolean; } interface GradeData { @@ -39,14 +42,7 @@ interface GradeData { all: number; } -const GradesPopup = ({ - deptCode, - courseNumber, - instructor = '', - classes, - isMobileScreen, - showLink = true, -}: GradesPopupProps) => { +const GradesPopup = ({ deptCode, courseNumber, instructor = '', classes, isMobileScreen }: GradesPopupProps) => { const [loading, setLoading] = useState(true); const [graphTitle, setGraphTitle] = useState(null); const [gradeData, setGradeData] = useState(null); @@ -98,27 +94,23 @@ const GradesPopup = ({ return (
{graphTitle}
- {gradeData && ( - - - - - - - - - )} - {showLink && ( - - )} + + {' '} + {gradeData && ( + + + + + + + + + )} +
); } diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx index 7ed9827e2..59855632f 100644 --- a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx +++ b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx @@ -262,9 +262,7 @@ const GPACell = withStyles(styles)((props: GPACellProps) => { deptCode={deptCode} courseNumber={courseNumber} instructor={instructor} - classes={classes} isMobileScreen={useMediaQuery(`(max-width: ${MOBILE_BREAKPOINT}`)} - showLink={false} />
From 7aadb4afa46ef114dd9a5262626f243f0e16a55f Mon Sep 17 00:00:00 2001 From: MinhxNguyen7 Date: Sat, 26 Aug 2023 15:13:30 +0700 Subject: [PATCH 15/89] Clear gradesCache when clearing websocCache --- apps/antalmanac/src/lib/helpers.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/antalmanac/src/lib/helpers.ts b/apps/antalmanac/src/lib/helpers.ts index 25ea77d23..ea194a397 100644 --- a/apps/antalmanac/src/lib/helpers.ts +++ b/apps/antalmanac/src/lib/helpers.ts @@ -104,6 +104,7 @@ const websocCache: { [key: string]: CacheEntry } = {}; export function clearCache() { Object.keys(websocCache).forEach((key) => delete websocCache[key]); //https://stackoverflow.com/a/19316873/14587004 + Object.keys(gradesCache).forEach((key) => delete gradesCache[key]); //https://stackoverflow.com/a/19316873/14587004 } function cleanParams(record: Record) { From a556c05a8656bba0d463cf699eca56e73c14ce13 Mon Sep 17 00:00:00 2001 From: MinhxNguyen7 Date: Sat, 26 Aug 2023 15:13:56 +0700 Subject: [PATCH 16/89] Lock for gradesCache --- apps/antalmanac/src/lib/helpers.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/apps/antalmanac/src/lib/helpers.ts b/apps/antalmanac/src/lib/helpers.ts index ea194a397..6ca81d03f 100644 --- a/apps/antalmanac/src/lib/helpers.ts +++ b/apps/antalmanac/src/lib/helpers.ts @@ -230,7 +230,7 @@ export interface Grades { gradeNPCount: number; } -const gradesCache: { [key: string]: Grades } = {}; +const gradesCache: { [key: string]: Grades | null } = {}; /* * Query the PeterPortal GraphQL API for a course's grades with caching @@ -241,16 +241,23 @@ const gradesCache: { [key: string]: Grades } = {}; * * @returns Grades */ -export async function queryGrades(deptCode: string, courseNumber: string, instructor = '') { +export async function queryGrades(deptCode: string, courseNumber: string, instructor = ''): Promise { instructor = instructor.replace('STAFF', '').trim(); // Ignore STAFF const instructorFilter = instructor ? `instructor: "${instructor}"` : ''; const cacheKey = deptCode + courseNumber + instructor; if (gradesCache[cacheKey]) { - return gradesCache[cacheKey]; + // If cache is null, there's a request in progress + while (gradesCache[cacheKey] === null) { + await new Promise((resolve) => setTimeout(resolve, 200)); // Wait before checking cache again + console.log('Waiting for cache ', cacheKey); + } + return gradesCache[cacheKey] as Grades; } + gradesCache[cacheKey] = null; // Set cache to null to indicate request in progress + const queryString = `{ aggregateGrades(department: "${deptCode}", courseNumber: "${courseNumber}", ${instructorFilter}) { gradeDistribution { From 6f245d6b1b6fd15e25883381c055dce1138d1ebc Mon Sep 17 00:00:00 2001 From: MinhxNguyen7 Date: Sat, 26 Aug 2023 15:40:56 +0700 Subject: [PATCH 17/89] Helpful graphQL request failure --- .../RightPane/SectionTable/GradesPopup.tsx | 5 +++ .../SectionTable/SectionTableBody.tsx | 2 +- apps/antalmanac/src/lib/helpers.ts | 32 +++++++++++-------- 3 files changed, 25 insertions(+), 14 deletions(-) diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/GradesPopup.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/GradesPopup.tsx index b6c0936a5..a32e628a8 100644 --- a/apps/antalmanac/src/components/RightPane/SectionTable/GradesPopup.tsx +++ b/apps/antalmanac/src/components/RightPane/SectionTable/GradesPopup.tsx @@ -54,6 +54,11 @@ const GradesPopup = ({ deptCode, courseNumber, instructor = '', classes, isMobil try { const courseGrades = await queryGrades(deptCode, courseNumber, instructor); + if (!courseGrades) { + setLoading(false); + setGraphTitle('Grades are not available for this class.'); + return; + } const data = []; for (const [key, value] of Object.entries(courseGrades)) { diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx index 59855632f..d99d289ae 100644 --- a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx +++ b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx @@ -218,7 +218,7 @@ const GPACell = withStyles(styles)((props: GPACellProps) => { for (const instructor of instructors.filter((instructor) => instructor !== 'STAFF')) { const grades = await queryGrades(deptCode, courseNumber, instructor); - if (grades.averageGPA) { + if (grades?.averageGPA) { setGpa(grades.averageGPA.toFixed(2).toString()); setInstructor(instructor); return; diff --git a/apps/antalmanac/src/lib/helpers.ts b/apps/antalmanac/src/lib/helpers.ts index 6ca81d03f..5230c7361 100644 --- a/apps/antalmanac/src/lib/helpers.ts +++ b/apps/antalmanac/src/lib/helpers.ts @@ -15,7 +15,7 @@ interface GradesGraphQLResponse { }; } -export async function queryGraphQL(queryString: string): Promise { +export async function queryGraphQL(queryString: string): Promise { const query = JSON.stringify({ query: queryString, }); @@ -28,7 +28,12 @@ export async function queryGraphQL(queryString: string): Promise; + + const json = await res.json(); + + if (!res.ok || json.data === null) return null; + + return json as Promise; } export interface CourseDetails { deptCode: string; @@ -230,7 +235,9 @@ export interface Grades { gradeNPCount: number; } -const gradesCache: { [key: string]: Grades | null } = {}; +// null means that the request failed +// undefined means that the request is in progress +const gradesCache: { [key: string]: Grades | null | undefined } = {}; /* * Query the PeterPortal GraphQL API for a course's grades with caching @@ -241,22 +248,23 @@ const gradesCache: { [key: string]: Grades | null } = {}; * * @returns Grades */ -export async function queryGrades(deptCode: string, courseNumber: string, instructor = ''): Promise { +export async function queryGrades(deptCode: string, courseNumber: string, instructor = ''): Promise { instructor = instructor.replace('STAFF', '').trim(); // Ignore STAFF const instructorFilter = instructor ? `instructor: "${instructor}"` : ''; const cacheKey = deptCode + courseNumber + instructor; - if (gradesCache[cacheKey]) { - // If cache is null, there's a request in progress - while (gradesCache[cacheKey] === null) { - await new Promise((resolve) => setTimeout(resolve, 200)); // Wait before checking cache again + // If cache is null, that request failed last time, and we try again + if (cacheKey in gradesCache && gradesCache[cacheKey] !== null) { + // If cache is undefined, there's a request in progress + while (gradesCache[cacheKey] === undefined) { + await new Promise((resolve) => setTimeout(resolve, 350)); // Wait before checking cache again console.log('Waiting for cache ', cacheKey); } return gradesCache[cacheKey] as Grades; } - gradesCache[cacheKey] = null; // Set cache to null to indicate request in progress + gradesCache[cacheKey] = undefined; // Set cache to undefined to indicate request in progress const queryString = `{ aggregateGrades(department: "${deptCode}", courseNumber: "${courseNumber}", ${instructorFilter}) { @@ -274,11 +282,9 @@ export async function queryGrades(deptCode: string, courseNumber: string, instru }`; const resp = await queryGraphQL(queryString); - const grades = resp.data.aggregateGrades.gradeDistribution; - - gradesCache[cacheKey] = grades; + gradesCache[cacheKey] = resp?.data?.aggregateGrades?.gradeDistribution; - return grades; + return gradesCache[cacheKey] as Grades; } export function combineSOCObjects(SOCObjects: WebsocAPIResponse[]) { From 66b07a4a7c9b696873b49d9ae06f09c1ebdcb29b Mon Sep 17 00:00:00 2001 From: MinhxNguyen7 Date: Sat, 26 Aug 2023 15:41:45 +0700 Subject: [PATCH 18/89] Remove debug print --- apps/antalmanac/src/lib/helpers.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/antalmanac/src/lib/helpers.ts b/apps/antalmanac/src/lib/helpers.ts index 5230c7361..67d400cd9 100644 --- a/apps/antalmanac/src/lib/helpers.ts +++ b/apps/antalmanac/src/lib/helpers.ts @@ -259,7 +259,6 @@ export async function queryGrades(deptCode: string, courseNumber: string, instru // If cache is undefined, there's a request in progress while (gradesCache[cacheKey] === undefined) { await new Promise((resolve) => setTimeout(resolve, 350)); // Wait before checking cache again - console.log('Waiting for cache ', cacheKey); } return gradesCache[cacheKey] as Grades; } From 829502f7b9c099c87844f73f34ddd9ecfaebf282 Mon Sep 17 00:00:00 2001 From: MinhxNguyen7 Date: Sat, 26 Aug 2023 20:32:18 +0700 Subject: [PATCH 19/89] Add classname to graph anchor --- .../src/components/RightPane/SectionTable/GradesPopup.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/GradesPopup.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/GradesPopup.tsx index a32e628a8..7f25df522 100644 --- a/apps/antalmanac/src/components/RightPane/SectionTable/GradesPopup.tsx +++ b/apps/antalmanac/src/components/RightPane/SectionTable/GradesPopup.tsx @@ -103,6 +103,7 @@ const GradesPopup = ({ deptCode, courseNumber, instructor = '', classes, isMobil href={`https://zotistics.com/?&selectQuarter=&selectYear=&selectDep=${encodedDept}&classNum=${courseNumber}&code=&submit=Submit`} target="_blank" rel="noopener noreferrer" + className={classes.graphAnchor} > {' '} {gradeData && ( From c427213876ef71c765600e39097a6b3ef370e6c8 Mon Sep 17 00:00:00 2001 From: MinhxNguyen7 Date: Sat, 26 Aug 2023 20:53:47 +0700 Subject: [PATCH 20/89] Disable scroll when popover --- .../components/RightPane/SectionTable/SectionTableBody.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx index d99d289ae..de867ab2b 100644 --- a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx +++ b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx @@ -34,10 +34,8 @@ import { mobileContext } from '$components/MobileHome'; import locationIds from '$lib/location_ids'; import { translateWebSOCTimeTo24HourTime, parseDaysString } from '$stores/calendarizeHelpers'; +// TODO: Style each component directly instead of using nth-child like some monkey const styles: Styles = (theme) => ({ - popover: { - pointerEvents: 'none', - }, sectionCode: { display: 'inline-flex', cursor: 'pointer', From da49e2590c227e322fe8363113c42b4e72642dd6 Mon Sep 17 00:00:00 2001 From: MinhxNguyen7 Date: Sat, 26 Aug 2023 21:49:23 +0700 Subject: [PATCH 21/89] Styled table for good spacing and side scrolling --- .../RightPane/SectionTable/SectionTable.tsx | 46 ++++--------------- .../SectionTable/SectionTableBody.tsx | 18 +++++--- .../SectionTable/SectionTableButtons.tsx | 7 ++- 3 files changed, 26 insertions(+), 45 deletions(-) diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTable.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTable.tsx index fe49ed783..4e91e0a3b 100644 --- a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTable.tsx +++ b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTable.tsx @@ -16,7 +16,7 @@ import ShowChartIcon from '@material-ui/icons/ShowChart'; // import AlmanacGraph from '../EnrollmentGraph/EnrollmentGraph'; uncomment when we get past enrollment data back and restore the files (https://github.com/icssc/AntAlmanac/tree/5e89e035e66f00608042871d43730ba785f756b0/src/components/RightPane/SectionTable/EnrollmentGraph) import { useCallback, useEffect, useState } from 'react'; import { MOBILE_BREAKPOINT } from '../../../globals'; -import RightPaneStore, { type SectionTableColumn } from '../RightPaneStore'; +import RightPaneStore, { SECTION_TABLE_COLUMNS, type SectionTableColumn } from '../RightPaneStore'; import CourseInfoBar from './CourseInfoBar'; import CourseInfoButton from './CourseInfoButton'; import GradesPopup from './GradesPopup'; @@ -35,39 +35,7 @@ const styles = { cellPadding: { padding: '0px 0px 0px 0px', }, - row: { - // TODO: Style this for mobile, possibly with minWidth pixels with side scrolling - '&:nth-child(1)': { - width: '8%', - }, - '&:nth-child(2)': { - width: '8%', - }, - '&:nth-child(3)': { - width: '8%', - }, - '&:nth-child(4)': { - width: '13%', - }, - '&:nth-child(5)': { - width: '5%', - }, - '&:nth-child(6)': { - width: '15%', - }, - '&:nth-child(7)': { - width: '8%', - }, - '&:nth-child(8)': { - width: '10%', - }, - '&:nth-child(9)': { - width: '8%', - }, - '&:nth-child(10)': { - width: '8%', - }, - }, + row: {}, container: {}, titleRow: {}, clearSchedule: {}, @@ -109,6 +77,10 @@ const SectionTable = (props: SectionTableProps) => { }; }, [handleColumnChange]); + // Limit table width to force side scrolling + const tableMinWidth = + ((isMobileScreen ? 600 : 780) * RightPaneStore.getActiveColumns().length) / SECTION_TABLE_COLUMNS.length; + return ( <>
{
- +
- - + + {Object.entries(tableHeaderColumns) .filter(([column]) => activeColumns.includes(column as SectionTableColumn)) .map(([column, label]) => { diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx index de867ab2b..1847dbc7e 100644 --- a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx +++ b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx @@ -43,6 +43,7 @@ const styles: Styles = (theme) => ({ color: isDarkMode() ? 'gold' : 'blueviolet', cursor: 'pointer', }, + alignSelf: 'center', }, row: { '&:nth-of-type(odd)': { @@ -58,9 +59,7 @@ const styles: Styles = (theme) => ({ opacity: isDarkMode() ? 0.6 : 1, }, }, - cell: { - fontSize: '0.85rem !important', - }, + cell: {}, link: { textDecoration: 'underline', color: isDarkMode() ? 'dodgerblue' : 'blue', @@ -72,7 +71,6 @@ const styles: Styles = (theme) => ({ background: 'none !important', border: 'none', padding: '0 !important', - fontSize: '0.85rem', // Not sure why this is not inherited }, paper: { padding: theme.spacing(), @@ -103,6 +101,12 @@ const styles: Styles = (theme) => ({ color: isDarkMode() ? 'dodgerblue' : 'blue', cursor: 'pointer', }, + codeCell: { + width: '8%', + }, + // statusCell: { + // width: '9%', + // }, }); const NoPaddingTableCell = withStyles({ @@ -118,7 +122,7 @@ const CourseCodeCell = withStyles(styles)((props: CourseCodeCellProps) => { const { classes, sectionCode } = props; return ( - + { @@ -422,7 +426,9 @@ const StatusCell = withStyles(styles)((props: StatusCellProps) => { // // ) return ( - {status} + + {status} + ); }); diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableButtons.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableButtons.tsx index dabac5d95..e00a848f2 100644 --- a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableButtons.tsx +++ b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableButtons.tsx @@ -17,6 +17,9 @@ import AppStore from '$stores/AppStore'; const fieldsToReset = ['courseCode', 'courseNumber', 'deptLabel', 'deptValue', 'GE', 'term']; const styles = { + optionsCell: { + width: '8%', + }, container: { display: 'flex', justifyContent: 'space-evenly', @@ -35,7 +38,7 @@ export const ColorAndDelete = withStyles(styles)((props: ColorAndDeleteProps) => const isMobileScreen = useMediaQuery(`(max-width: ${MOBILE_BREAKPOINT}`); return ( - +
{ @@ -116,7 +119,7 @@ export const ScheduleAddCell = withStyles(styles)((props: ScheduleAddCellProps) }; return ( - +
{scheduleConflict ? ( From 249c13b37540db199f92bcc51b29515db9ce2027 Mon Sep 17 00:00:00 2001 From: MinhxNguyen7 Date: Sat, 26 Aug 2023 21:53:59 +0700 Subject: [PATCH 22/89] Remove Todo --- .../src/components/RightPane/SectionTable/SectionTableBody.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx index 1847dbc7e..0a1372bb5 100644 --- a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx +++ b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx @@ -34,7 +34,6 @@ import { mobileContext } from '$components/MobileHome'; import locationIds from '$lib/location_ids'; import { translateWebSOCTimeTo24HourTime, parseDaysString } from '$stores/calendarizeHelpers'; -// TODO: Style each component directly instead of using nth-child like some monkey const styles: Styles = (theme) => ({ sectionCode: { display: 'inline-flex', From b313fa64f9bee3b2534fc030dcd0bdb511eb9ace Mon Sep 17 00:00:00 2001 From: Kevin Wu Date: Tue, 22 Aug 2023 04:26:37 -0700 Subject: [PATCH 23/89] Add Tabs for Sat/Sun on Map (#632) * Added Weekend Tabs * Spring cleanup * Reverted 'All' back to '', moved arrays to global scope * Update apps/antalmanac/src/components/Map/Map.tsx Co-authored-by: Aponia * Refactored '' to 'All' and cleaned up accordingly * refactor: memo-ized map stuff (#666) * refactor: memo-ized map stuff * fix: section table not opening correct map link * fix: looser match for same building location --------- Co-authored-by: Aponia --- apps/antalmanac/src/components/Map/Map.tsx | 134 +++++++++++------- .../SectionTable/SectionTableBody.tsx | 14 +- 2 files changed, 96 insertions(+), 52 deletions(-) diff --git a/apps/antalmanac/src/components/Map/Map.tsx b/apps/antalmanac/src/components/Map/Map.tsx index cdf72527d..d19a00427 100644 --- a/apps/antalmanac/src/components/Map/Map.tsx +++ b/apps/antalmanac/src/components/Map/Map.tsx @@ -1,6 +1,6 @@ import './Map.css'; -import { Fragment, useEffect, useRef, useCallback, useState, createRef } from 'react'; +import { Fragment, useEffect, useRef, useCallback, useState, createRef, useMemo } from 'react'; import { useSearchParams, useNavigate } from 'react-router-dom'; import L, { type Map, type LatLngTuple } from 'leaflet'; import { MapContainer, TileLayer } from 'react-leaflet'; @@ -22,10 +22,9 @@ const ATTRIBUTION_MARKUP = const url = `https://api.mapbox.com/styles/v1/mapbox/streets-v11/tiles/{z}/{x}/{y}?access_token=${ACCESS_TOKEN}`; -/** - * empty day is alias for "All Days" - */ -const days = ['', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri']; +const WORK_WEEK = ['All', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri']; +const FULL_WEEK = ['All', 'Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; +const weekendIndices = [0, 6]; interface MarkerContent { key: string; @@ -96,25 +95,43 @@ export default function CourseMap() { const [searchParams] = useSearchParams(); const [selectedDayIndex, setSelectedDay] = useState(0); const [markers, setMarkers] = useState(getCoursesPerBuilding()); + const [calendarEvents, setCalendarEvents] = useState(AppStore.getCourseEventsInCalendar()); const updateMarkers = useCallback(() => { setMarkers(getCoursesPerBuilding()); }, [setMarkers, getCoursesPerBuilding]); + const updateCalendarEvents = useCallback(() => { + setCalendarEvents(AppStore.getCourseEventsInCalendar()); + }, [setCalendarEvents]); + useEffect(() => { AppStore.on('addedCoursesChange', updateMarkers); AppStore.on('currentScheduleIndexChange', updateMarkers); + return () => { AppStore.removeListener('addedCoursesChange', updateMarkers); AppStore.removeListener('currentScheduleIndexChange', updateMarkers); }; - }, [AppStore, updateMarkers]); + }, [updateMarkers]); + + useEffect(() => { + AppStore.on('addedCoursesChange', updateCalendarEvents); + AppStore.on('currentScheduleIndexChange', updateCalendarEvents); + + return () => { + AppStore.removeListener('addedCoursesChange', updateCalendarEvents); + AppStore.removeListener('currentScheduleIndexChange', updateCalendarEvents); + }; + }, [updateCalendarEvents]); useEffect(() => { const locationID = Number(searchParams.get('location') ?? 0); const building = locationID in buildingCatalogue ? buildingCatalogue[locationID] : undefined; - if (building == null) return; + if (building == null) { + return; + } setTimeout(() => { map.current?.flyTo([building.lat + 0.001, building.lng], 18, { duration: 250, animate: false }); @@ -122,53 +139,78 @@ export default function CourseMap() { }, 250); }, [searchParams]); - const handleChange = (_event: React.SyntheticEvent, newValue: number) => { - setSelectedDay(newValue); - }; + const handleChange = useCallback( + (_event: React.SyntheticEvent, newValue: number) => { + setSelectedDay(newValue); + }, + [setSelectedDay] + ); + + const handleSearch = useCallback( + (_event: React.SyntheticEvent, value: [string, Building] | null) => { + navigate(`/map?location=${value?.[0]}`); + }, + [navigate] + ); + + const days = useMemo(() => { + const hasWeekendCourse = calendarEvents.some((event) => weekendIndices.includes(event.start.getDay())); + return hasWeekendCourse ? FULL_WEEK : WORK_WEEK; + }, [calendarEvents]); + + const today = useMemo(() => { + return days[selectedDayIndex]; + }, [days, selectedDayIndex]); - const handleSearch = (_event: React.SyntheticEvent, value: [string, Building] | null) => { - navigate(`/map?location=${value?.[0]}`); - }; + const focusedLocation = useMemo(() => { + const locationID = Number(searchParams.get('location') ?? 0); - const locationID = Number(searchParams.get('location') ?? 0); + const focusedBuilding = locationID in buildingCatalogue ? buildingCatalogue[locationID] : undefined; - const focusedBuilding = locationID in buildingCatalogue ? buildingCatalogue[locationID] : undefined; + if (focusedBuilding == null) { + return undefined; + } - const focusedLocation = focusedBuilding - ? { - ...focusedBuilding, - image: focusedBuilding.imageURLs[0], - acronym: focusedBuilding.name.substring( - focusedBuilding?.name.indexOf('(') + 1, - focusedBuilding?.name.indexOf(')') - ), - location: focusedBuilding.name, - } - : undefined; + const acronym = focusedBuilding.name.substring( + focusedBuilding?.name.indexOf('(') + 1, + focusedBuilding?.name.indexOf(')') + ); - const today = days[selectedDayIndex]; + return { + ...focusedBuilding, + image: focusedBuilding.imageURLs[0], + acronym, + location: focusedBuilding.name, + }; + }, [searchParams]); /** * Get markers for unique courses (identified by section ID) that occur today, sorted by start time. * A duplicate section code found later in the array will have a higher index. */ - const markersToDisplay = Object.keys(markers) - .flatMap((markerKey) => markers[markerKey].filter((course) => course.start.toString().includes(today))) - .sort((a, b) => a.start.getTime() - b.start.getTime()) - .filter( - (a, index, array) => array.findIndex((otherCourse) => otherCourse.sectionCode === a.sectionCode) === index - ); + const markersToDisplay = useMemo(() => { + const markerValues = Object.keys(markers).flatMap((markerKey) => markers[markerKey]); + + const markersToday = + today === 'All' ? markerValues : markerValues.filter((course) => course.start.toString().includes(today)); + + return markersToday + .sort((a, b) => a.start.getTime() - b.start.getTime()) + .filter((marker, i, arr) => arr.findIndex((other) => other.sectionCode === marker.sectionCode) === i); + }, [markers, today]); /** * Every two markers grouped as [start, destination] tuples for the routes. */ - const startDestPairs = markersToDisplay.reduce((acc, cur, index) => { - acc.push([cur]); - if (index > 0) { - acc[index - 1].push(cur); - } - return acc; - }, [] as (typeof markersToDisplay)[]); + const startDestPairs = useMemo(() => { + return markersToDisplay.reduce((acc, cur, index) => { + acc.push([cur]); + if (index > 0) { + acc[index - 1].push(cur); + } + return acc; + }, [] as (typeof markersToDisplay)[]); + }, [markersToDisplay]); return ( @@ -177,11 +219,7 @@ export default function CourseMap() { {days.map((day) => ( - + ))} {/* Draw out routes if the user is viewing a specific day. */} - {today !== '' && + {today !== 'All' && startDestPairs.map((startDestPair) => { const latLngTuples = startDestPair.map((marker) => [marker.lat, marker.lng] as LatLngTuple); const color = startDestPair[0]?.color; @@ -213,13 +251,13 @@ export default function CourseMap() { // Find all courses that occur in the same building prior to this one to stack them properly. const coursesSameBuildingPrior = markersToDisplay .slice(0, index) - .filter((m) => m.bldg === marker.bldg); + .filter((m) => m.bldg.split(' ')[0] === marker.bldg.split(' ')[0]); return ( diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx index 0a1372bb5..b958eea5b 100644 --- a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx +++ b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx @@ -287,8 +287,8 @@ const LocationsCell = withStyles(styles)((props: LocationsCellProps) => { return ( {meetings.map((meeting) => { - const [buildingName = ''] = meeting.bldg; - const buildingId = locationIds[buildingName] ?? 69420; + const [buildingName = ''] = meeting.bldg[0].split(' '); + const buildingId = locationIds[buildingName]; return meeting.bldg[0] !== 'TBA' ? ( Date: Sat, 26 Aug 2023 22:24:05 -0700 Subject: [PATCH 24/89] Add support for new API time formats (#669) * Initial commit, baby steps * Add support for finals * Spring cleaning * Update type and refactor code for readability * Refactor code * Spring cleaning * Install PP types @68 * Update types * Handle varying finals statuses * Handle TBA courses * Handle possible null * Format to 12 hour time * Add day to finals string * Refactor code and correct types * Refactor scheduleConflict * Correct types, primarily for null * Update apps/antalmanac/src/stores/calendarizeHelpers.ts Co-authored-by: Aponia * Update apps/antalmanac/src/stores/calendarizeHelpers.ts Co-authored-by: Aponia * Update apps/antalmanac/src/stores/calendarizeHelpers.ts Co-authored-by: Aponia * Update apps/antalmanac/src/stores/calendarizeHelpers.ts Co-authored-by: Aponia * Correct new function implementations * Revert new implementation of calendarizeFinals * Revert calendarizeCustomEvents * refactor: calendarize functions * test: new calendarize helpers with old * doc: added more documentation to reference util * Add back ternary for formatting --------- Co-authored-by: Aponia --- .editorconfig | 9 + apps/antalmanac/package.json | 2 +- .../Calendar/CourseCalendarEvent.tsx | 35 +- .../SectionTable/SectionTableBody.tsx | 17 +- .../SectionTable/SectionTableButtons.tsx | 2 +- apps/antalmanac/src/lib/api/endpoints.ts | 5 +- apps/antalmanac/src/lib/utils.ts | 30 ++ .../src/stores/calendarizeHelpers.ts | 342 +++++++++--------- .../tests/calendarize-helpers.test.ts | 227 ++++++++++++ apps/antalmanac/tsconfig.json | 4 +- packages/peterportal-schemas/src/websoc.ts | 111 +++--- pnpm-lock.yaml | 8 +- 12 files changed, 562 insertions(+), 230 deletions(-) create mode 100644 .editorconfig create mode 100644 apps/antalmanac/src/lib/utils.ts create mode 100644 apps/antalmanac/tests/calendarize-helpers.test.ts diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 000000000..e291365a9 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 4 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true diff --git a/apps/antalmanac/package.json b/apps/antalmanac/package.json index ae38c8cc4..39fa48f3d 100644 --- a/apps/antalmanac/package.json +++ b/apps/antalmanac/package.json @@ -93,7 +93,7 @@ "gh-pages": "^5.0.0", "husky": "^8.0.3", "lint-staged": "^13.1.1", - "peterportal-api-next-types": "1.0.0-beta.2", + "peterportal-api-next-types": "1.0.0-rc.2.68.0", "prettier": "^2.8.4", "typescript": "^4.9.5", "vite": "^4.2.1", diff --git a/apps/antalmanac/src/components/Calendar/CourseCalendarEvent.tsx b/apps/antalmanac/src/components/Calendar/CourseCalendarEvent.tsx index 74fd4a176..f363e878f 100644 --- a/apps/antalmanac/src/components/Calendar/CourseCalendarEvent.tsx +++ b/apps/antalmanac/src/components/Calendar/CourseCalendarEvent.tsx @@ -14,6 +14,7 @@ import { clickToCopy, isDarkMode } from '$lib/helpers'; import AppStore from '$stores/AppStore'; import locationIds from '$lib/location_ids'; import { mobileContext } from '$components/MobileHome'; +import { translate24To12HourTime } from '$stores/calendarizeHelpers'; const styles: Styles = { courseContainer: { @@ -87,7 +88,22 @@ interface CommonCalendarEvent extends Event { export interface CourseEvent extends CommonCalendarEvent { bldg: string; // E.g., ICS 174, which is actually building + room - finalExam: string; + finalExam: { + examStatus: 'NO_FINAL' | 'TBA_FINAL' | 'SCHEDULED_FINAL'; + dayOfWeek: 'Sun' | 'Mon' | 'Tue' | 'Wed' | 'Thu' | 'Fri' | 'Sat' | null; + month: number | null; + day: number | null; + startTime: { + hour: number; + minute: number; + } | null; + endTime: { + hour: number; + minute: number; + } | null; + bldg: string[] | null; + }; + courseTitle: string; instructors: string[]; isCustomEvent: false; sectionCode: string; @@ -113,6 +129,8 @@ interface CourseCalendarEventProps { closePopover: () => void; } +const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + const CourseCalendarEvent = (props: CourseCalendarEventProps) => { const paperRef = useRef(null); @@ -145,6 +163,19 @@ const CourseCalendarEvent = (props: CourseCalendarEventProps) => { const buildingId = locationIds[buildingName] ?? 69420; + let finalExamString = ''; + if (finalExam.examStatus == 'NO_FINAL') { + finalExamString = 'No Final'; + } else if (finalExam.examStatus == 'TBA_FINAL') { + finalExamString = 'Final TBA'; + } else { + if (finalExam.startTime && finalExam.endTime && finalExam.month) { + const timeString = translate24To12HourTime(finalExam.startTime, finalExam.endTime); + + finalExamString = `${finalExam.dayOfWeek} ${MONTHS[finalExam.month]} ${finalExam.day} ${timeString}`; + } + } + return (
@@ -207,7 +238,7 @@ const CourseCalendarEvent = (props: CourseCalendarEventProps) => {
- + diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx index b958eea5b..7b680361b 100644 --- a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx +++ b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx @@ -32,7 +32,7 @@ import { clickToCopy, CourseDetails, isDarkMode, queryGrades } from '$lib/helper import AppStore from '$stores/AppStore'; import { mobileContext } from '$components/MobileHome'; import locationIds from '$lib/location_ids'; -import { translateWebSOCTimeTo24HourTime, parseDaysString } from '$stores/calendarizeHelpers'; +import { normalizeTime, parseDaysString, translate24To12HourTime } from '$stores/calendarizeHelpers'; const styles: Styles = (theme) => ({ sectionCode: { @@ -290,7 +290,7 @@ const LocationsCell = withStyles(styles)((props: LocationsCellProps) => { const [buildingName = ''] = meeting.bldg[0].split(' '); const buildingId = locationIds[buildingName]; return meeting.bldg[0] !== 'TBA' ? ( - + { return ( {meetings.map((meeting) => { - const timeString = meeting.time.replace(/\s/g, '').split('-').join(' - '); - return {`${meeting.days} ${timeString}`}; + if (meeting.timeIsTBA) { + return TBA; + } + + if (meeting.startTime && meeting.endTime) { + const timeString = translate24To12HourTime(meeting.startTime, meeting.endTime); + + return {`${meeting.days} ${timeString}`}; + } })} ); @@ -479,7 +486,7 @@ const SectionTableBody = withStyles(styles)((props: SectionTableBodyProps) => { const sectionDetails = useMemo(() => { return { daysOccurring: parseDaysString(section.meetings[0].days), - ...translateWebSOCTimeTo24HourTime(section.meetings[0].time), + ...normalizeTime(section.meetings[0]), }; }, [section.meetings[0]]); diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableButtons.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableButtons.tsx index e00a848f2..951ae796a 100644 --- a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableButtons.tsx +++ b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableButtons.tsx @@ -80,7 +80,7 @@ export const ScheduleAddCell = withStyles(styles)((props: ScheduleAddCellProps) const closeAndAddCourse = (scheduleIndex: number, specificSchedule?: boolean) => { popupState.close(); for (const meeting of section.meetings) { - if (meeting.time === 'TBA') { + if (meeting.timeIsTBA) { openSnackbar('success', 'Online/TBA class added'); // See Added Classes." break; diff --git a/apps/antalmanac/src/lib/api/endpoints.ts b/apps/antalmanac/src/lib/api/endpoints.ts index 888f9ea3a..e381fb1ce 100644 --- a/apps/antalmanac/src/lib/api/endpoints.ts +++ b/apps/antalmanac/src/lib/api/endpoints.ts @@ -17,4 +17,7 @@ export const REGISTER_NOTIFICATIONS_ENDPOINT = endpointTransform('/api/notificat // PeterPortal API export const PETERPORTAL_GRAPHQL_ENDPOINT = 'https://api-next.peterportal.org/v1/graphql'; export const PETERPORTAL_REST_ENDPOINT = 'https://api-next.peterportal.org/v1/rest'; -export const PETERPORTAL_WEBSOC_ENDPOINT = `${PETERPORTAL_REST_ENDPOINT}/websoc`; + +// Testing API +export const PETERPORTAL_REST_ENDPOINT_68 = 'https://staging-68.api-next.peterportal.org/v1/rest'; +export const PETERPORTAL_WEBSOC_ENDPOINT = `${PETERPORTAL_REST_ENDPOINT_68}/websoc`; diff --git a/apps/antalmanac/src/lib/utils.ts b/apps/antalmanac/src/lib/utils.ts new file mode 100644 index 000000000..657c70252 --- /dev/null +++ b/apps/antalmanac/src/lib/utils.ts @@ -0,0 +1,30 @@ +export function notNull(value: T): value is NonNullable { + return value != null; +} + +/** + * Given a reference array and an input, generate an array of booleans for each position in the + * reference array, indicating whether the value at that position is in the input. + * + * @example + * reference = ['a', 'b', 'c', 'd', 'e'] + * input = 'ace' + * result = [true, false, true, false, true] + * + * Can be used in conjunection with {@link notNull} to get only indices. + * + * @example + * + * ```ts + * + * const reference = ['a', 'b', 'c', 'd', 'e']; + * const input = 'ace'; + * + * const occurringReferences = getReferencesOccurring(reference, input) + * const indicesOrNull = occurringReferences.map((occurring, index) => occurring ? index : null) + * const indices = indicesOrNull.filter(notNull) + * ``` + */ +export function getReferencesOccurring(reference: string[], input?: string | string[] | null): boolean[] { + return input ? reference.map((reference) => input.includes(reference)) : reference.map(() => false); +} diff --git a/apps/antalmanac/src/stores/calendarizeHelpers.ts b/apps/antalmanac/src/stores/calendarizeHelpers.ts index 97180517e..0451af34e 100644 --- a/apps/antalmanac/src/stores/calendarizeHelpers.ts +++ b/apps/antalmanac/src/stores/calendarizeHelpers.ts @@ -1,179 +1,128 @@ import { ScheduleCourse } from '@packages/antalmanac-types'; +import { HourMinute } from 'peterportal-api-next-types'; import { CourseEvent, CustomEvent } from '$components/Calendar/CourseCalendarEvent'; import { RepeatingCustomEvent } from '$components/Calendar/Toolbar/CustomEventDialog/CustomEventDialog'; - -export const calendarizeCourseEvents = (currentCourses: ScheduleCourse[] = []) => { - const courseEventsInCalendar: CourseEvent[] = []; - - for (const course of currentCourses) { - for (const meeting of course.section.meetings) { - const timeString = meeting.time.replace(/\s/g, ''); - - if (timeString !== 'TBA') { - const [, startHrStr, startMinStr, endHrStr, endMinStr, ampm] = timeString.match( - /(\d{1,2}):(\d{2})-(\d{1,2}):(\d{2})(p?)/ - ) as RegExpMatchArray; - - let startHr = parseInt(startHrStr, 10); - const startMin = parseInt(startMinStr, 10); - let endHr = parseInt(endHrStr, 10); - const endMin = parseInt(endMinStr, 10); - - const dates = [ - meeting.days.includes('Su'), - meeting.days.includes('M'), - meeting.days.includes('Tu'), - meeting.days.includes('W'), - meeting.days.includes('Th'), - meeting.days.includes('F'), - meeting.days.includes('Sa'), - ]; - - if (ampm === 'p' && endHr !== 12) { - startHr += 12; - endHr += 12; - if (startHr > endHr) startHr -= 12; - } - - dates.forEach((shouldBeInCal, index) => { - if (shouldBeInCal) { - const newEvent = { - color: course.section.color, - term: course.term, - title: course.deptCode + ' ' + course.courseNumber, - courseTitle: course.courseTitle, - bldg: meeting.bldg[0], - instructors: course.section.instructors, - sectionCode: course.section.sectionCode, - sectionType: course.section.sectionType, - start: new Date(2018, 0, index, startHr, startMin), - finalExam: course.section.finalExam, - end: new Date(2018, 0, index, endHr, endMin), - isCustomEvent: false as const, - }; - - courseEventsInCalendar.push(newEvent); - } - }); - } - } - } - - return courseEventsInCalendar; -}; - -export const calendarizeFinals = (currentCourses: ScheduleCourse[] = []) => { - const finalsEventsInCalendar: CourseEvent[] = []; - - for (const course of currentCourses) { - const finalExam = course.section.finalExam; - if (finalExam.length > 5) { - const [, date, , , startStr, startMinStr, endStr, endMinStr, ampm] = finalExam.match( - /([A-za-z]+) ([A-Za-z]+) *(\d{1,2}) *(\d{1,2}):(\d{2})-(\d{1,2}):(\d{2})(am|pm)/ - ) as RegExpMatchArray; - // TODO: this block is almost the same as in calenarizeCourseEvents. we should refactor to remove the duplicate code. - let startHour = parseInt(startStr, 10); - const startMin = parseInt(startMinStr, 10); - let endHour = parseInt(endStr, 10); - const endMin = parseInt(endMinStr, 10); - const weekdayInclusion: boolean[] = [ - date.includes('Sat'), - date.includes('Sun'), - date.includes('Mon'), - date.includes('Tue'), - date.includes('Wed'), - date.includes('Thu'), - date.includes('Fri'), - ]; - if (ampm === 'pm' && endHour !== 12) { - startHour += 12; - endHour += 12; - if (startHour > endHour) startHour -= 12; - } - - weekdayInclusion.forEach((shouldBeInCal, index) => { - if (shouldBeInCal) - finalsEventsInCalendar.push({ - title: course.deptCode + ' ' + course.courseNumber, - sectionCode: course.section.sectionCode, - sectionType: 'Fin', - bldg: course.section.meetings[0].bldg[0], +import { notNull, getReferencesOccurring } from '$lib/utils'; + +const COURSE_WEEK_DAYS = ['Su', 'M', 'Tu', 'W', 'Th', 'F', 'Sa']; + +const FINALS_WEEK_DAYS = ['Sat', 'Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri']; + +export function calendarizeCourseEvents(currentCourses: ScheduleCourse[] = []): CourseEvent[] { + return currentCourses.flatMap((course) => { + return course.section.meetings + .filter((meeting) => !meeting.timeIsTBA && meeting.startTime && meeting.endTime && meeting.days) + .flatMap((meeting) => { + const startHour = meeting.startTime?.hour; + const startMin = meeting.startTime?.minute; + const endHour = meeting.endTime?.hour; + const endMin = meeting.endTime?.minute; + + /** + * An array of booleans indicating whether a course meeting occurs on that day. + * + * @example [false, true, false, true, false, true, false], i.e. [M, W, F] + */ + const daysOccurring = getReferencesOccurring(COURSE_WEEK_DAYS, meeting.days); + + /** + * Only include the day indices that the meeting occurs. + * + * @example [false, true, false, true, false, true, false] -> [1, 3, 5] + */ + const dayIndicesOccurring = daysOccurring + .map((day, index) => (day ? index : undefined)) + .filter(notNull); + + return dayIndicesOccurring.map((dayIndex) => { + return { color: course.section.color, - start: new Date(2018, 0, index - 1, startHour, startMin), - end: new Date(2018, 0, index - 1, endHour, endMin), - finalExam: course.section.finalExam, - instructors: course.section.instructors, term: course.term, + title: `${course.deptCode} ${course.courseNumber}`, + courseTitle: course.courseTitle, + bldg: meeting.bldg[0], + instructors: course.section.instructors, + sectionCode: course.section.sectionCode, + sectionType: course.section.sectionType, + start: new Date(2018, 0, dayIndex, startHour, startMin), + end: new Date(2018, 0, dayIndex, endHour, endMin), + finalExam: course.section.finalExam, isCustomEvent: false, - }); - }); - } - } - - return finalsEventsInCalendar; -}; - -export const calendarizeCustomEvents = (currentCustomEvents: RepeatingCustomEvent[] = []) => { - const customEventsInCalendar: CustomEvent[] = []; - - for (const customEvent of currentCustomEvents) { - for (let dayIndex = 0; dayIndex < customEvent.days.length; dayIndex++) { - if (customEvent.days[dayIndex]) { - const startHour = parseInt(customEvent.start.slice(0, 2), 10); - const startMin = parseInt(customEvent.start.slice(3, 5), 10); - const endHour = parseInt(customEvent.end.slice(0, 2), 10); - const endMin = parseInt(customEvent.end.slice(3, 5), 10); - - customEventsInCalendar.push({ - customEventID: customEvent.customEventID, - color: customEvent.color ?? '#000000', - start: new Date(2018, 0, dayIndex, startHour, startMin), - isCustomEvent: true, - end: new Date(2018, 0, dayIndex, endHour, endMin), - title: customEvent.title, + }; }); - } - } - } - - return customEventsInCalendar; -}; - -interface TranslatedWebSOCTime { - startTime: string; - endTime: string; + }); + }); } -/** - * @param time The time string. - * @returns The start and end time of a course in a 24 hour time with a leading zero (##:##). - * @returns undefined if there is no WebSOC time (e.g. 'TBA', undefined) - */ -export function translateWebSOCTimeTo24HourTime(time: string): TranslatedWebSOCTime | undefined { - const timeString = time.replace(/\s/g, ''); - - if (timeString !== 'TBA' && timeString !== undefined) { - const [, startHrStr, startMinStr, endHrStr, endMinStr, ampm] = timeString.match( - /(\d{1,2}):(\d{2})-(\d{1,2}):(\d{2})(p?)/ - ) as RegExpMatchArray; - - let startHr = parseInt(startHrStr, 10); - let endHr = parseInt(endHrStr, 10); - - if (ampm === 'p' && endHr !== 12) { - startHr += 12; - endHr += 12; - if (startHr > endHr) startHr -= 12; - } - - // Times are standardized to ##:## (i.e. leading zero) for correct comparisons as strings - return { - startTime: `${startHr < 10 ? `0${startHr}` : startHr}:${startMinStr}`, - endTime: `${endHr < 10 ? `0${endHr}` : endHr}:${endMinStr}`, - }; - } +export function calendarizeFinals(currentCourses: ScheduleCourse[] = []): CourseEvent[] { + return currentCourses + .filter( + (course) => + course.section.finalExam.examStatus === 'SCHEDULED_FINAL' && + course.section.finalExam.startTime && + course.section.finalExam.endTime && + course.section.finalExam.dayOfWeek + ) + .flatMap((course) => { + const finalExam = course.section.finalExam; + const startHour = finalExam.startTime?.hour; + const startMin = finalExam.startTime?.minute; + const endHour = finalExam.endTime?.hour; + const endMin = finalExam.endTime?.minute; + + /** + * An array of booleans indicating whether the day at that index is a day that the final. + * + * @example [false, false, false, true, false, true, false], i.e. [T, Th] + */ + const weekdaysOccurring = getReferencesOccurring(FINALS_WEEK_DAYS, course.section.finalExam.dayOfWeek); + + /** + * Only include the day indices that the final is occurring. + * + * @example [false, false, false, true, false, true, false] -> [3, 5] + */ + const dayIndicesOcurring = weekdaysOccurring.map((day, index) => (day ? index : undefined)).filter(notNull); + + return dayIndicesOcurring.map((dayIndex) => { + return { + color: course.section.color, + term: course.term, + title: `${course.deptCode} ${course.courseNumber}`, + courseTitle: course.courseTitle, + bldg: course.section.meetings[0].bldg[0], + instructors: course.section.instructors, + sectionCode: course.section.sectionCode, + sectionType: 'Fin', + start: new Date(2018, 0, dayIndex - 1, startHour, startMin), + end: new Date(2018, 0, dayIndex - 1, endHour, endMin), + finalExam: course.section.finalExam, + isCustomEvent: false, + }; + }); + }); +} - return undefined; +export function calendarizeCustomEvents(currentCustomEvents: RepeatingCustomEvent[] = []): CustomEvent[] { + return currentCustomEvents.flatMap((customEvent) => { + const dayIndiciesOcurring = customEvent.days.map((day, index) => (day ? index : undefined)).filter(notNull); + + return dayIndiciesOcurring.map((dayIndex) => { + const startHour = parseInt(customEvent.start.slice(0, 2), 10); + const startMin = parseInt(customEvent.start.slice(3, 5), 10); + const endHour = parseInt(customEvent.end.slice(0, 2), 10); + const endMin = parseInt(customEvent.end.slice(3, 5), 10); + + return { + customEventID: customEvent.customEventID, + color: customEvent.color ?? '#000000', + start: new Date(2018, 0, dayIndex, startHour, startMin), + isCustomEvent: true, + end: new Date(2018, 0, dayIndex, endHour, endMin), + title: customEvent.title, + }; + }); + }); } export const SHORT_DAYS = ['Su', 'M', 'Tu', 'W', 'Th', 'F', 'Sa']; @@ -189,7 +138,11 @@ export const SHORT_DAY_REGEX = new RegExp(`(${SHORT_DAYS.join('|')})`, 'g'); * @example 'TuTh' -> [2, 4] * @example 'MWFTh' -> [1, 3, 5, 4] */ -export function parseDaysString(daysString: string): number[] { +export function parseDaysString(daysString: string | null): number[] | null { + if (daysString == null) { + return null; + } + const days: number[] = []; let match: RegExpExecArray | null; @@ -200,3 +153,58 @@ export function parseDaysString(daysString: string): number[] { return days; } + +interface NormalizedWebSOCTime { + startTime: string; + endTime: string; +} + +/** + * @param section + * @returns The start and end time of a course in a 24 hour time with a leading zero (##:##). + * @returns undefined if there is no WebSOC time (e.g. 'TBA', undefined) + */ +interface NormalizeTimeOptions { + timeIsTBA?: boolean; + startTime?: HourMinute | null; + endTime?: HourMinute | null; +} + +/** + * @param section + * @returns The start and end time of a course in a 24 hour time with a leading zero (##:##). + * @returns undefined if there is no WebSOC time (e.g. 'TBA', undefined) + */ +export function normalizeTime(options: NormalizeTimeOptions): NormalizedWebSOCTime | undefined { + if (options.timeIsTBA || !options.startTime || !options.endTime) { + return; + } + + // Times are normalized to ##:## (10:00, 09:00 etc) + const startHour = `${options.startTime.hour}`.padStart(2, '0'); + const endHour = `${options.endTime.hour}`.padStart(2, '0'); + + const startTime = `${startHour}:${options.startTime.minute}`; + const endTime = `${endHour}:${options.endTime.minute}`; + + return { startTime, endTime }; +} + +export function translate24To12HourTime(startTime?: HourMinute, endTime?: HourMinute): string | undefined { + if (!startTime || !endTime) { + return; + } + + const timeSuffix = endTime.hour >= 12 ? 'PM' : 'AM'; + + const formattedStartHour = `${startTime.hour > 12 ? startTime.hour - 12 : startTime.hour}`; + const formattedEndHour = `${endTime.hour > 12 ? endTime.hour - 12 : endTime.hour}`; + + const formattedStartMinute = `${startTime.minute}`; + const formattedEndMinute = `${endTime.minute}`; + + const meetingStartTime = `${formattedStartHour}:${formattedStartMinute.padStart(2, '0')}`; + const meetingEndTime = `${formattedEndHour}:${formattedEndMinute.padStart(2, '0')}`; + + return `${meetingStartTime} - ${meetingEndTime} ${timeSuffix}`; +} diff --git a/apps/antalmanac/tests/calendarize-helpers.test.ts b/apps/antalmanac/tests/calendarize-helpers.test.ts new file mode 100644 index 000000000..8b51ae060 --- /dev/null +++ b/apps/antalmanac/tests/calendarize-helpers.test.ts @@ -0,0 +1,227 @@ +import { describe, test, expect } from 'vitest'; +import type { ScheduleCourse } from '@packages/antalmanac-types'; +import type { CourseEvent, CustomEvent } from '$components/Calendar/CourseCalendarEvent'; +import type { RepeatingCustomEvent } from '$components/Calendar/Toolbar/CustomEventDialog/CustomEventDialog'; +import { calendarizeCourseEvents, calendarizeCustomEvents, calendarizeFinals } from '$stores/calendarizeHelpers'; + +describe('calendarize-helpers', () => { + const courses: ScheduleCourse[] = [ + { + courseComment: 'string', + courseNumber: 'string', + courseTitle: 'string', + deptCode: 'string', + prerequisiteLink: 'string', + section: { + color: 'string', + sectionCode: 'string', + sectionType: 'string', + sectionNum: 'string', + units: 'string', + instructors: [], + meetings: [ + { + timeIsTBA: false, + bldg: [], + days: 'MWF', + startTime: { + hour: 1, + minute: 2, + }, + endTime: { + hour: 3, + minute: 4, + }, + }, + ], + finalExam: { + examStatus: 'SCHEDULED_FINAL', + dayOfWeek: 'Sun', + month: 2, + day: 3, + startTime: { + hour: 1, + minute: 2, + }, + endTime: { + hour: 3, + minute: 4, + }, + bldg: [], + }, + maxCapacity: 'string', + numCurrentlyEnrolled: { + totalEnrolled: 'string', + sectionEnrolled: 'string', + }, + numOnWaitlist: 'string', + numWaitlistCap: 'string', + numRequested: 'string', + numNewOnlyReserved: 'string', + restrictions: 'string', + status: 'OPEN', + sectionComment: 'string', + }, + term: 'string', + }, + ]; + + const customEvents: RepeatingCustomEvent[] = [ + { + title: 'title', + start: '01:02', + end: '03:04', + days: [true, false, true, false, true, false, true], + customEventID: 0, + color: '#000000', + }, + ]; + + test('calendarizeCourseEvents', () => { + const newResult = calendarizeCourseEvents(courses); + const oldResult = oldCalendarizeCourseEvents(courses); + expect(newResult).toStrictEqual(oldResult); + }); + + test('calendarizeFinals', () => { + const newResult = calendarizeFinals(courses); + const oldResult = oldCalendarizeFinals(courses); + expect(newResult).toStrictEqual(oldResult); + }); + + test('calendarizeCustomEvents', () => { + const newResult = calendarizeCustomEvents(customEvents); + const oldResult = oldClendarizeCustomEvents(customEvents); + expect(newResult).toStrictEqual(oldResult); + }); +}); + +/** + * TODO: Remove this and replace with an array of expected values. + */ +export function oldCalendarizeCourseEvents(currentCourses: ScheduleCourse[] = []): CourseEvent[] { + const courseEventsInCalendar: CourseEvent[] = []; + + for (const course of currentCourses) { + for (const meeting of course.section.meetings) { + if (!meeting.timeIsTBA && meeting.startTime && meeting.endTime && meeting.days) { + const startHour = meeting.startTime.hour; + const startMin = meeting.startTime.minute; + const endHour = meeting.endTime.hour; + const endMin = meeting.endTime.minute; + + const dates: boolean[] = [ + meeting.days.includes('Su'), + meeting.days.includes('M'), + meeting.days.includes('Tu'), + meeting.days.includes('W'), + meeting.days.includes('Th'), + meeting.days.includes('F'), + meeting.days.includes('Sa'), + ]; + + dates.forEach((shouldBeInCal, index) => { + if (shouldBeInCal) { + courseEventsInCalendar.push({ + color: course.section.color, + term: course.term, + title: course.deptCode + ' ' + course.courseNumber, + courseTitle: course.courseTitle, + bldg: meeting.bldg[0], + instructors: course.section.instructors, + sectionCode: course.section.sectionCode, + sectionType: course.section.sectionType, + start: new Date(2018, 0, index, startHour, startMin), + end: new Date(2018, 0, index, endHour, endMin), + finalExam: course.section.finalExam, + isCustomEvent: false as const, + }); + } + }); + } + } + } + + return courseEventsInCalendar; +} + +/** + * TODO: Remove this and replace with an array of expected values. + */ +export function oldCalendarizeFinals(currentCourses: ScheduleCourse[] = []): CourseEvent[] { + const finalsEventsInCalendar: CourseEvent[] = []; + + for (const course of currentCourses) { + const finalExam = course.section.finalExam; + + if ( + finalExam.examStatus == 'SCHEDULED_FINAL' && + finalExam.startTime && + finalExam.endTime && + finalExam.dayOfWeek + ) { + // TODO: this block is almost the same as in calenarizeCourseEvents. we should refactor to remove the duplicate code. + + const startHour = finalExam.startTime.hour; + const startMin = finalExam.startTime.minute; + const endHour = finalExam.endTime.hour; + const endMin = finalExam.endTime.minute; + + const weekdayInclusion: boolean[] = [ + finalExam.dayOfWeek.includes('Sat'), + finalExam.dayOfWeek.includes('Sun'), + finalExam.dayOfWeek.includes('Mon'), + finalExam.dayOfWeek.includes('Tue'), + finalExam.dayOfWeek.includes('Wed'), + finalExam.dayOfWeek.includes('Thu'), + finalExam.dayOfWeek.includes('Fri'), + ]; + + weekdayInclusion.forEach((shouldBeInCal, index) => { + if (shouldBeInCal) + finalsEventsInCalendar.push({ + color: course.section.color, + term: course.term, + title: course.deptCode + ' ' + course.courseNumber, + courseTitle: course.courseTitle, + bldg: course.section.meetings[0].bldg[0], + instructors: course.section.instructors, + sectionCode: course.section.sectionCode, + sectionType: 'Fin', + start: new Date(2018, 0, index - 1, startHour, startMin), + end: new Date(2018, 0, index - 1, endHour, endMin), + finalExam: course.section.finalExam, + isCustomEvent: false, + }); + }); + } + } + + return finalsEventsInCalendar; +} + +/** + * TODO: Remove this and replace with an array of expected values. + */ +export function oldClendarizeCustomEvents(currentCustomEvents: RepeatingCustomEvent[] = []): CustomEvent[] { + const customEventsInCalendar: CustomEvent[] = []; + for (const customEvent of currentCustomEvents) { + for (let dayIndex = 0; dayIndex < customEvent.days.length; dayIndex++) { + if (customEvent.days[dayIndex]) { + const startHour = parseInt(customEvent.start.slice(0, 2), 10); + const startMin = parseInt(customEvent.start.slice(3, 5), 10); + const endHour = parseInt(customEvent.end.slice(0, 2), 10); + const endMin = parseInt(customEvent.end.slice(3, 5), 10); + customEventsInCalendar.push({ + customEventID: customEvent.customEventID, + color: customEvent.color ?? '#000000', + start: new Date(2018, 0, dayIndex, startHour, startMin), + isCustomEvent: true, + end: new Date(2018, 0, dayIndex, endHour, endMin), + title: customEvent.title, + }); + } + } + } + return customEventsInCalendar; +} diff --git a/apps/antalmanac/tsconfig.json b/apps/antalmanac/tsconfig.json index 5f82b8e52..498a6c3a7 100644 --- a/apps/antalmanac/tsconfig.json +++ b/apps/antalmanac/tsconfig.json @@ -22,7 +22,7 @@ "$lib/*": ["src/lib/*"], "$providers/*": ["src/providers/*"], "$routes/*": ["src/routes/*"], - "$stores/*": ["src/stores/*"], + "$stores/*": ["src/stores/*"] } - }, + } } diff --git a/packages/peterportal-schemas/src/websoc.ts b/packages/peterportal-schemas/src/websoc.ts index ffa3de248..3551f7e74 100644 --- a/packages/peterportal-schemas/src/websoc.ts +++ b/packages/peterportal-schemas/src/websoc.ts @@ -1,81 +1,98 @@ -import { type Infer, arrayOf, type } from "arktype"; -import { type Quarter, quarters } from "peterportal-api-next-types"; -import enumerate from "./enumerate"; +import { type Infer, arrayOf, type, union } from 'arktype'; +import { type Quarter, quarters } from 'peterportal-api-next-types'; +import enumerate from './enumerate'; + +export const HourMinute = type({ + hour: 'number', + minute: 'number', +}); export const WebsocSectionMeeting = type({ - days: "string", - time: "string", - bldg: "string[]", + timeIsTBA: 'boolean', + bldg: 'string[]', + days: 'string | null', + startTime: union(HourMinute, 'null'), + endTime: union(HourMinute, 'null'), }); export const WebsocSectionEnrollment = type({ - totalEnrolled: "string", - sectionEnrolled: "string", + totalEnrolled: 'string', + sectionEnrolled: 'string', +}); + +export const WebSocSectionFinals = type({ + examStatus: '"NO_FINAL" | "TBA_FINAL" | "SCHEDULED_FINAL"', + dayOfWeek: '"Sun" | "Mon" | "Tue" | "Wed" | "Thu" | "Fri" | "Sat" | null', + month: 'number | null', + day: 'number | null', + startTime: union(HourMinute, 'null'), + endTime: union(HourMinute, 'null'), + bldg: 'string[] | null', }); export const WebsocSection = type({ - sectionCode: "string", - sectionType: "string", - sectionNum: "string", - units: "string", - instructors: "string[]", - meetings: arrayOf(WebsocSectionMeeting), - finalExam: "string", - maxCapacity: "string", - numCurrentlyEnrolled: WebsocSectionEnrollment, - numOnWaitlist: "string", - numWaitlistCap: "string", - numRequested: "string", - numNewOnlyReserved: "string", - restrictions: "string", - status: enumerate(["OPEN", "Waitl", "FULL", "NewOnly"] as const), - sectionComment: "string", + sectionCode: 'string', + sectionType: 'string', + sectionNum: 'string', + units: 'string', + instructors: 'string[]', + meetings: arrayOf(WebsocSectionMeeting), + finalExam: WebSocSectionFinals, + maxCapacity: 'string', + numCurrentlyEnrolled: WebsocSectionEnrollment, + numOnWaitlist: 'string', + numWaitlistCap: 'string', + numRequested: 'string', + numNewOnlyReserved: 'string', + restrictions: 'string', + status: enumerate(['OPEN', 'Waitl', 'FULL', 'NewOnly'] as const), + sectionComment: 'string', }); export const WebsocCourse = type({ - deptCode: "string", - courseNumber: "string", - courseTitle: "string", - courseComment: "string", - prerequisiteLink: "string", - // sections: arrayOf(WebsocSection), - // Commenting out sections because I don't know how to override this property + deptCode: 'string', + courseNumber: 'string', + courseTitle: 'string', + courseComment: 'string', + prerequisiteLink: 'string', + // sections: arrayOf(WebsocSection), + // Commenting out sections because I don't know how to override this property }); export const WebsocDepartment = type({ - deptName: "string", - deptCode: "string", - deptComment: "string", - courses: arrayOf(WebsocCourse), - sectionCodeRangeComments: "string[]", - courseNumberRangeComments: "string[]", + deptName: 'string', + deptCode: 'string', + deptComment: 'string', + courses: arrayOf(WebsocCourse), + sectionCodeRangeComments: 'string[]', + courseNumberRangeComments: 'string[]', }); export const WebsocSchool = type({ - schoolName: "string", - schoolComment: "string", - departments: arrayOf(WebsocDepartment), + schoolName: 'string', + schoolComment: 'string', + departments: arrayOf(WebsocDepartment), }); export const Term = type({ - year: "string", - quarter: enumerate(quarters), + year: 'string', + quarter: enumerate(quarters), }); export const WebsocAPIResponse = { - schools: arrayOf(WebsocSchool), + schools: arrayOf(WebsocSchool), }; export const Department = type({ - deptLabel: "string", - deptValue: "string", + deptLabel: 'string', + deptValue: 'string', }); export const DepartmentResponse = arrayOf(Department); export const TermData = type({ - shortName: "string" as Infer<`${string} ${Quarter}`>, - longName: "string", + shortName: 'string' as Infer<`${string} ${Quarter}`>, + longName: 'string', }); export const TermResponse = arrayOf(TermData); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a741100e9..08f4836c2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -238,8 +238,8 @@ importers: specifier: ^13.1.1 version: 13.1.2 peterportal-api-next-types: - specifier: 1.0.0-beta.2 - version: 1.0.0-beta.2 + specifier: 1.0.0-rc.2.68.0 + version: 1.0.0-rc.2.68.0 prettier: specifier: ^2.8.4 version: 2.8.4 @@ -6743,8 +6743,8 @@ packages: resolution: {integrity: sha512-sbQmYiH21t6wIsgFXStJcBZWhMOCjKQspLGdpUEmpYQaR4tL1kwGQ+KNix5EwLJxHM9BnMtK1BJcwu6fOTeqMQ==} dev: false - /peterportal-api-next-types@1.0.0-beta.2: - resolution: {integrity: sha512-y7uFk8nYmOQ9oWxFMCztUoIIDU5eFAfWct92HPGEbIKvTJTDBeNTb57/5qQP0kq0QE0n26dtmLMADvLkbN7P1Q==} + /peterportal-api-next-types@1.0.0-rc.2.68.0: + resolution: {integrity: sha512-gq0k53abt6ea9roA+GlSgP3Rbv+0tC4rGw4gGbrahh+ZNnmTGdlZSF8ISq07DbQ7td8dBev4gMrjrZq+Xn500A==} dev: true /picocolors@1.0.0: From 93f43bbede38dae43853b7553a6cba8acc401623 Mon Sep 17 00:00:00 2001 From: Kevin Wu Date: Sat, 26 Aug 2023 22:30:44 -0700 Subject: [PATCH 25/89] Revert "Add support for new API time formats (#669)" (#678) This reverts commit ccf14dc49d2ae4f9cd93f9ed755e4fa04b99a18c. --- .editorconfig | 9 - apps/antalmanac/package.json | 2 +- .../Calendar/CourseCalendarEvent.tsx | 35 +- .../SectionTable/SectionTableBody.tsx | 17 +- .../SectionTable/SectionTableButtons.tsx | 2 +- apps/antalmanac/src/lib/api/endpoints.ts | 5 +- apps/antalmanac/src/lib/utils.ts | 30 -- .../src/stores/calendarizeHelpers.ts | 342 +++++++++--------- .../tests/calendarize-helpers.test.ts | 227 ------------ apps/antalmanac/tsconfig.json | 4 +- packages/peterportal-schemas/src/websoc.ts | 111 +++--- pnpm-lock.yaml | 8 +- 12 files changed, 230 insertions(+), 562 deletions(-) delete mode 100644 .editorconfig delete mode 100644 apps/antalmanac/src/lib/utils.ts delete mode 100644 apps/antalmanac/tests/calendarize-helpers.test.ts diff --git a/.editorconfig b/.editorconfig deleted file mode 100644 index e291365a9..000000000 --- a/.editorconfig +++ /dev/null @@ -1,9 +0,0 @@ -root = true - -[*] -charset = utf-8 -indent_style = space -indent_size = 4 -end_of_line = lf -insert_final_newline = true -trim_trailing_whitespace = true diff --git a/apps/antalmanac/package.json b/apps/antalmanac/package.json index 39fa48f3d..ae38c8cc4 100644 --- a/apps/antalmanac/package.json +++ b/apps/antalmanac/package.json @@ -93,7 +93,7 @@ "gh-pages": "^5.0.0", "husky": "^8.0.3", "lint-staged": "^13.1.1", - "peterportal-api-next-types": "1.0.0-rc.2.68.0", + "peterportal-api-next-types": "1.0.0-beta.2", "prettier": "^2.8.4", "typescript": "^4.9.5", "vite": "^4.2.1", diff --git a/apps/antalmanac/src/components/Calendar/CourseCalendarEvent.tsx b/apps/antalmanac/src/components/Calendar/CourseCalendarEvent.tsx index f363e878f..74fd4a176 100644 --- a/apps/antalmanac/src/components/Calendar/CourseCalendarEvent.tsx +++ b/apps/antalmanac/src/components/Calendar/CourseCalendarEvent.tsx @@ -14,7 +14,6 @@ import { clickToCopy, isDarkMode } from '$lib/helpers'; import AppStore from '$stores/AppStore'; import locationIds from '$lib/location_ids'; import { mobileContext } from '$components/MobileHome'; -import { translate24To12HourTime } from '$stores/calendarizeHelpers'; const styles: Styles = { courseContainer: { @@ -88,22 +87,7 @@ interface CommonCalendarEvent extends Event { export interface CourseEvent extends CommonCalendarEvent { bldg: string; // E.g., ICS 174, which is actually building + room - finalExam: { - examStatus: 'NO_FINAL' | 'TBA_FINAL' | 'SCHEDULED_FINAL'; - dayOfWeek: 'Sun' | 'Mon' | 'Tue' | 'Wed' | 'Thu' | 'Fri' | 'Sat' | null; - month: number | null; - day: number | null; - startTime: { - hour: number; - minute: number; - } | null; - endTime: { - hour: number; - minute: number; - } | null; - bldg: string[] | null; - }; - courseTitle: string; + finalExam: string; instructors: string[]; isCustomEvent: false; sectionCode: string; @@ -129,8 +113,6 @@ interface CourseCalendarEventProps { closePopover: () => void; } -const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; - const CourseCalendarEvent = (props: CourseCalendarEventProps) => { const paperRef = useRef(null); @@ -163,19 +145,6 @@ const CourseCalendarEvent = (props: CourseCalendarEventProps) => { const buildingId = locationIds[buildingName] ?? 69420; - let finalExamString = ''; - if (finalExam.examStatus == 'NO_FINAL') { - finalExamString = 'No Final'; - } else if (finalExam.examStatus == 'TBA_FINAL') { - finalExamString = 'Final TBA'; - } else { - if (finalExam.startTime && finalExam.endTime && finalExam.month) { - const timeString = translate24To12HourTime(finalExam.startTime, finalExam.endTime); - - finalExamString = `${finalExam.dayOfWeek} ${MONTHS[finalExam.month]} ${finalExam.day} ${timeString}`; - } - } - return (
@@ -238,7 +207,7 @@ const CourseCalendarEvent = (props: CourseCalendarEventProps) => {
- + diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx index 7b680361b..b958eea5b 100644 --- a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx +++ b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx @@ -32,7 +32,7 @@ import { clickToCopy, CourseDetails, isDarkMode, queryGrades } from '$lib/helper import AppStore from '$stores/AppStore'; import { mobileContext } from '$components/MobileHome'; import locationIds from '$lib/location_ids'; -import { normalizeTime, parseDaysString, translate24To12HourTime } from '$stores/calendarizeHelpers'; +import { translateWebSOCTimeTo24HourTime, parseDaysString } from '$stores/calendarizeHelpers'; const styles: Styles = (theme) => ({ sectionCode: { @@ -290,7 +290,7 @@ const LocationsCell = withStyles(styles)((props: LocationsCellProps) => { const [buildingName = ''] = meeting.bldg[0].split(' '); const buildingId = locationIds[buildingName]; return meeting.bldg[0] !== 'TBA' ? ( - + { return ( {meetings.map((meeting) => { - if (meeting.timeIsTBA) { - return TBA; - } - - if (meeting.startTime && meeting.endTime) { - const timeString = translate24To12HourTime(meeting.startTime, meeting.endTime); - - return {`${meeting.days} ${timeString}`}; - } + const timeString = meeting.time.replace(/\s/g, '').split('-').join(' - '); + return {`${meeting.days} ${timeString}`}; })} ); @@ -486,7 +479,7 @@ const SectionTableBody = withStyles(styles)((props: SectionTableBodyProps) => { const sectionDetails = useMemo(() => { return { daysOccurring: parseDaysString(section.meetings[0].days), - ...normalizeTime(section.meetings[0]), + ...translateWebSOCTimeTo24HourTime(section.meetings[0].time), }; }, [section.meetings[0]]); diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableButtons.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableButtons.tsx index 951ae796a..e00a848f2 100644 --- a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableButtons.tsx +++ b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableButtons.tsx @@ -80,7 +80,7 @@ export const ScheduleAddCell = withStyles(styles)((props: ScheduleAddCellProps) const closeAndAddCourse = (scheduleIndex: number, specificSchedule?: boolean) => { popupState.close(); for (const meeting of section.meetings) { - if (meeting.timeIsTBA) { + if (meeting.time === 'TBA') { openSnackbar('success', 'Online/TBA class added'); // See Added Classes." break; diff --git a/apps/antalmanac/src/lib/api/endpoints.ts b/apps/antalmanac/src/lib/api/endpoints.ts index e381fb1ce..888f9ea3a 100644 --- a/apps/antalmanac/src/lib/api/endpoints.ts +++ b/apps/antalmanac/src/lib/api/endpoints.ts @@ -17,7 +17,4 @@ export const REGISTER_NOTIFICATIONS_ENDPOINT = endpointTransform('/api/notificat // PeterPortal API export const PETERPORTAL_GRAPHQL_ENDPOINT = 'https://api-next.peterportal.org/v1/graphql'; export const PETERPORTAL_REST_ENDPOINT = 'https://api-next.peterportal.org/v1/rest'; - -// Testing API -export const PETERPORTAL_REST_ENDPOINT_68 = 'https://staging-68.api-next.peterportal.org/v1/rest'; -export const PETERPORTAL_WEBSOC_ENDPOINT = `${PETERPORTAL_REST_ENDPOINT_68}/websoc`; +export const PETERPORTAL_WEBSOC_ENDPOINT = `${PETERPORTAL_REST_ENDPOINT}/websoc`; diff --git a/apps/antalmanac/src/lib/utils.ts b/apps/antalmanac/src/lib/utils.ts deleted file mode 100644 index 657c70252..000000000 --- a/apps/antalmanac/src/lib/utils.ts +++ /dev/null @@ -1,30 +0,0 @@ -export function notNull(value: T): value is NonNullable { - return value != null; -} - -/** - * Given a reference array and an input, generate an array of booleans for each position in the - * reference array, indicating whether the value at that position is in the input. - * - * @example - * reference = ['a', 'b', 'c', 'd', 'e'] - * input = 'ace' - * result = [true, false, true, false, true] - * - * Can be used in conjunection with {@link notNull} to get only indices. - * - * @example - * - * ```ts - * - * const reference = ['a', 'b', 'c', 'd', 'e']; - * const input = 'ace'; - * - * const occurringReferences = getReferencesOccurring(reference, input) - * const indicesOrNull = occurringReferences.map((occurring, index) => occurring ? index : null) - * const indices = indicesOrNull.filter(notNull) - * ``` - */ -export function getReferencesOccurring(reference: string[], input?: string | string[] | null): boolean[] { - return input ? reference.map((reference) => input.includes(reference)) : reference.map(() => false); -} diff --git a/apps/antalmanac/src/stores/calendarizeHelpers.ts b/apps/antalmanac/src/stores/calendarizeHelpers.ts index 0451af34e..97180517e 100644 --- a/apps/antalmanac/src/stores/calendarizeHelpers.ts +++ b/apps/antalmanac/src/stores/calendarizeHelpers.ts @@ -1,128 +1,179 @@ import { ScheduleCourse } from '@packages/antalmanac-types'; -import { HourMinute } from 'peterportal-api-next-types'; import { CourseEvent, CustomEvent } from '$components/Calendar/CourseCalendarEvent'; import { RepeatingCustomEvent } from '$components/Calendar/Toolbar/CustomEventDialog/CustomEventDialog'; -import { notNull, getReferencesOccurring } from '$lib/utils'; - -const COURSE_WEEK_DAYS = ['Su', 'M', 'Tu', 'W', 'Th', 'F', 'Sa']; - -const FINALS_WEEK_DAYS = ['Sat', 'Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri']; - -export function calendarizeCourseEvents(currentCourses: ScheduleCourse[] = []): CourseEvent[] { - return currentCourses.flatMap((course) => { - return course.section.meetings - .filter((meeting) => !meeting.timeIsTBA && meeting.startTime && meeting.endTime && meeting.days) - .flatMap((meeting) => { - const startHour = meeting.startTime?.hour; - const startMin = meeting.startTime?.minute; - const endHour = meeting.endTime?.hour; - const endMin = meeting.endTime?.minute; - - /** - * An array of booleans indicating whether a course meeting occurs on that day. - * - * @example [false, true, false, true, false, true, false], i.e. [M, W, F] - */ - const daysOccurring = getReferencesOccurring(COURSE_WEEK_DAYS, meeting.days); - - /** - * Only include the day indices that the meeting occurs. - * - * @example [false, true, false, true, false, true, false] -> [1, 3, 5] - */ - const dayIndicesOccurring = daysOccurring - .map((day, index) => (day ? index : undefined)) - .filter(notNull); - - return dayIndicesOccurring.map((dayIndex) => { - return { - color: course.section.color, - term: course.term, - title: `${course.deptCode} ${course.courseNumber}`, - courseTitle: course.courseTitle, - bldg: meeting.bldg[0], - instructors: course.section.instructors, + +export const calendarizeCourseEvents = (currentCourses: ScheduleCourse[] = []) => { + const courseEventsInCalendar: CourseEvent[] = []; + + for (const course of currentCourses) { + for (const meeting of course.section.meetings) { + const timeString = meeting.time.replace(/\s/g, ''); + + if (timeString !== 'TBA') { + const [, startHrStr, startMinStr, endHrStr, endMinStr, ampm] = timeString.match( + /(\d{1,2}):(\d{2})-(\d{1,2}):(\d{2})(p?)/ + ) as RegExpMatchArray; + + let startHr = parseInt(startHrStr, 10); + const startMin = parseInt(startMinStr, 10); + let endHr = parseInt(endHrStr, 10); + const endMin = parseInt(endMinStr, 10); + + const dates = [ + meeting.days.includes('Su'), + meeting.days.includes('M'), + meeting.days.includes('Tu'), + meeting.days.includes('W'), + meeting.days.includes('Th'), + meeting.days.includes('F'), + meeting.days.includes('Sa'), + ]; + + if (ampm === 'p' && endHr !== 12) { + startHr += 12; + endHr += 12; + if (startHr > endHr) startHr -= 12; + } + + dates.forEach((shouldBeInCal, index) => { + if (shouldBeInCal) { + const newEvent = { + color: course.section.color, + term: course.term, + title: course.deptCode + ' ' + course.courseNumber, + courseTitle: course.courseTitle, + bldg: meeting.bldg[0], + instructors: course.section.instructors, + sectionCode: course.section.sectionCode, + sectionType: course.section.sectionType, + start: new Date(2018, 0, index, startHr, startMin), + finalExam: course.section.finalExam, + end: new Date(2018, 0, index, endHr, endMin), + isCustomEvent: false as const, + }; + + courseEventsInCalendar.push(newEvent); + } + }); + } + } + } + + return courseEventsInCalendar; +}; + +export const calendarizeFinals = (currentCourses: ScheduleCourse[] = []) => { + const finalsEventsInCalendar: CourseEvent[] = []; + + for (const course of currentCourses) { + const finalExam = course.section.finalExam; + if (finalExam.length > 5) { + const [, date, , , startStr, startMinStr, endStr, endMinStr, ampm] = finalExam.match( + /([A-za-z]+) ([A-Za-z]+) *(\d{1,2}) *(\d{1,2}):(\d{2})-(\d{1,2}):(\d{2})(am|pm)/ + ) as RegExpMatchArray; + // TODO: this block is almost the same as in calenarizeCourseEvents. we should refactor to remove the duplicate code. + let startHour = parseInt(startStr, 10); + const startMin = parseInt(startMinStr, 10); + let endHour = parseInt(endStr, 10); + const endMin = parseInt(endMinStr, 10); + const weekdayInclusion: boolean[] = [ + date.includes('Sat'), + date.includes('Sun'), + date.includes('Mon'), + date.includes('Tue'), + date.includes('Wed'), + date.includes('Thu'), + date.includes('Fri'), + ]; + if (ampm === 'pm' && endHour !== 12) { + startHour += 12; + endHour += 12; + if (startHour > endHour) startHour -= 12; + } + + weekdayInclusion.forEach((shouldBeInCal, index) => { + if (shouldBeInCal) + finalsEventsInCalendar.push({ + title: course.deptCode + ' ' + course.courseNumber, sectionCode: course.section.sectionCode, - sectionType: course.section.sectionType, - start: new Date(2018, 0, dayIndex, startHour, startMin), - end: new Date(2018, 0, dayIndex, endHour, endMin), + sectionType: 'Fin', + bldg: course.section.meetings[0].bldg[0], + color: course.section.color, + start: new Date(2018, 0, index - 1, startHour, startMin), + end: new Date(2018, 0, index - 1, endHour, endMin), finalExam: course.section.finalExam, + instructors: course.section.instructors, + term: course.term, isCustomEvent: false, - }; - }); + }); }); - }); -} + } + } -export function calendarizeFinals(currentCourses: ScheduleCourse[] = []): CourseEvent[] { - return currentCourses - .filter( - (course) => - course.section.finalExam.examStatus === 'SCHEDULED_FINAL' && - course.section.finalExam.startTime && - course.section.finalExam.endTime && - course.section.finalExam.dayOfWeek - ) - .flatMap((course) => { - const finalExam = course.section.finalExam; - const startHour = finalExam.startTime?.hour; - const startMin = finalExam.startTime?.minute; - const endHour = finalExam.endTime?.hour; - const endMin = finalExam.endTime?.minute; - - /** - * An array of booleans indicating whether the day at that index is a day that the final. - * - * @example [false, false, false, true, false, true, false], i.e. [T, Th] - */ - const weekdaysOccurring = getReferencesOccurring(FINALS_WEEK_DAYS, course.section.finalExam.dayOfWeek); - - /** - * Only include the day indices that the final is occurring. - * - * @example [false, false, false, true, false, true, false] -> [3, 5] - */ - const dayIndicesOcurring = weekdaysOccurring.map((day, index) => (day ? index : undefined)).filter(notNull); - - return dayIndicesOcurring.map((dayIndex) => { - return { - color: course.section.color, - term: course.term, - title: `${course.deptCode} ${course.courseNumber}`, - courseTitle: course.courseTitle, - bldg: course.section.meetings[0].bldg[0], - instructors: course.section.instructors, - sectionCode: course.section.sectionCode, - sectionType: 'Fin', - start: new Date(2018, 0, dayIndex - 1, startHour, startMin), - end: new Date(2018, 0, dayIndex - 1, endHour, endMin), - finalExam: course.section.finalExam, - isCustomEvent: false, - }; - }); - }); + return finalsEventsInCalendar; +}; + +export const calendarizeCustomEvents = (currentCustomEvents: RepeatingCustomEvent[] = []) => { + const customEventsInCalendar: CustomEvent[] = []; + + for (const customEvent of currentCustomEvents) { + for (let dayIndex = 0; dayIndex < customEvent.days.length; dayIndex++) { + if (customEvent.days[dayIndex]) { + const startHour = parseInt(customEvent.start.slice(0, 2), 10); + const startMin = parseInt(customEvent.start.slice(3, 5), 10); + const endHour = parseInt(customEvent.end.slice(0, 2), 10); + const endMin = parseInt(customEvent.end.slice(3, 5), 10); + + customEventsInCalendar.push({ + customEventID: customEvent.customEventID, + color: customEvent.color ?? '#000000', + start: new Date(2018, 0, dayIndex, startHour, startMin), + isCustomEvent: true, + end: new Date(2018, 0, dayIndex, endHour, endMin), + title: customEvent.title, + }); + } + } + } + + return customEventsInCalendar; +}; + +interface TranslatedWebSOCTime { + startTime: string; + endTime: string; } -export function calendarizeCustomEvents(currentCustomEvents: RepeatingCustomEvent[] = []): CustomEvent[] { - return currentCustomEvents.flatMap((customEvent) => { - const dayIndiciesOcurring = customEvent.days.map((day, index) => (day ? index : undefined)).filter(notNull); - - return dayIndiciesOcurring.map((dayIndex) => { - const startHour = parseInt(customEvent.start.slice(0, 2), 10); - const startMin = parseInt(customEvent.start.slice(3, 5), 10); - const endHour = parseInt(customEvent.end.slice(0, 2), 10); - const endMin = parseInt(customEvent.end.slice(3, 5), 10); - - return { - customEventID: customEvent.customEventID, - color: customEvent.color ?? '#000000', - start: new Date(2018, 0, dayIndex, startHour, startMin), - isCustomEvent: true, - end: new Date(2018, 0, dayIndex, endHour, endMin), - title: customEvent.title, - }; - }); - }); +/** + * @param time The time string. + * @returns The start and end time of a course in a 24 hour time with a leading zero (##:##). + * @returns undefined if there is no WebSOC time (e.g. 'TBA', undefined) + */ +export function translateWebSOCTimeTo24HourTime(time: string): TranslatedWebSOCTime | undefined { + const timeString = time.replace(/\s/g, ''); + + if (timeString !== 'TBA' && timeString !== undefined) { + const [, startHrStr, startMinStr, endHrStr, endMinStr, ampm] = timeString.match( + /(\d{1,2}):(\d{2})-(\d{1,2}):(\d{2})(p?)/ + ) as RegExpMatchArray; + + let startHr = parseInt(startHrStr, 10); + let endHr = parseInt(endHrStr, 10); + + if (ampm === 'p' && endHr !== 12) { + startHr += 12; + endHr += 12; + if (startHr > endHr) startHr -= 12; + } + + // Times are standardized to ##:## (i.e. leading zero) for correct comparisons as strings + return { + startTime: `${startHr < 10 ? `0${startHr}` : startHr}:${startMinStr}`, + endTime: `${endHr < 10 ? `0${endHr}` : endHr}:${endMinStr}`, + }; + } + + return undefined; } export const SHORT_DAYS = ['Su', 'M', 'Tu', 'W', 'Th', 'F', 'Sa']; @@ -138,11 +189,7 @@ export const SHORT_DAY_REGEX = new RegExp(`(${SHORT_DAYS.join('|')})`, 'g'); * @example 'TuTh' -> [2, 4] * @example 'MWFTh' -> [1, 3, 5, 4] */ -export function parseDaysString(daysString: string | null): number[] | null { - if (daysString == null) { - return null; - } - +export function parseDaysString(daysString: string): number[] { const days: number[] = []; let match: RegExpExecArray | null; @@ -153,58 +200,3 @@ export function parseDaysString(daysString: string | null): number[] | null { return days; } - -interface NormalizedWebSOCTime { - startTime: string; - endTime: string; -} - -/** - * @param section - * @returns The start and end time of a course in a 24 hour time with a leading zero (##:##). - * @returns undefined if there is no WebSOC time (e.g. 'TBA', undefined) - */ -interface NormalizeTimeOptions { - timeIsTBA?: boolean; - startTime?: HourMinute | null; - endTime?: HourMinute | null; -} - -/** - * @param section - * @returns The start and end time of a course in a 24 hour time with a leading zero (##:##). - * @returns undefined if there is no WebSOC time (e.g. 'TBA', undefined) - */ -export function normalizeTime(options: NormalizeTimeOptions): NormalizedWebSOCTime | undefined { - if (options.timeIsTBA || !options.startTime || !options.endTime) { - return; - } - - // Times are normalized to ##:## (10:00, 09:00 etc) - const startHour = `${options.startTime.hour}`.padStart(2, '0'); - const endHour = `${options.endTime.hour}`.padStart(2, '0'); - - const startTime = `${startHour}:${options.startTime.minute}`; - const endTime = `${endHour}:${options.endTime.minute}`; - - return { startTime, endTime }; -} - -export function translate24To12HourTime(startTime?: HourMinute, endTime?: HourMinute): string | undefined { - if (!startTime || !endTime) { - return; - } - - const timeSuffix = endTime.hour >= 12 ? 'PM' : 'AM'; - - const formattedStartHour = `${startTime.hour > 12 ? startTime.hour - 12 : startTime.hour}`; - const formattedEndHour = `${endTime.hour > 12 ? endTime.hour - 12 : endTime.hour}`; - - const formattedStartMinute = `${startTime.minute}`; - const formattedEndMinute = `${endTime.minute}`; - - const meetingStartTime = `${formattedStartHour}:${formattedStartMinute.padStart(2, '0')}`; - const meetingEndTime = `${formattedEndHour}:${formattedEndMinute.padStart(2, '0')}`; - - return `${meetingStartTime} - ${meetingEndTime} ${timeSuffix}`; -} diff --git a/apps/antalmanac/tests/calendarize-helpers.test.ts b/apps/antalmanac/tests/calendarize-helpers.test.ts deleted file mode 100644 index 8b51ae060..000000000 --- a/apps/antalmanac/tests/calendarize-helpers.test.ts +++ /dev/null @@ -1,227 +0,0 @@ -import { describe, test, expect } from 'vitest'; -import type { ScheduleCourse } from '@packages/antalmanac-types'; -import type { CourseEvent, CustomEvent } from '$components/Calendar/CourseCalendarEvent'; -import type { RepeatingCustomEvent } from '$components/Calendar/Toolbar/CustomEventDialog/CustomEventDialog'; -import { calendarizeCourseEvents, calendarizeCustomEvents, calendarizeFinals } from '$stores/calendarizeHelpers'; - -describe('calendarize-helpers', () => { - const courses: ScheduleCourse[] = [ - { - courseComment: 'string', - courseNumber: 'string', - courseTitle: 'string', - deptCode: 'string', - prerequisiteLink: 'string', - section: { - color: 'string', - sectionCode: 'string', - sectionType: 'string', - sectionNum: 'string', - units: 'string', - instructors: [], - meetings: [ - { - timeIsTBA: false, - bldg: [], - days: 'MWF', - startTime: { - hour: 1, - minute: 2, - }, - endTime: { - hour: 3, - minute: 4, - }, - }, - ], - finalExam: { - examStatus: 'SCHEDULED_FINAL', - dayOfWeek: 'Sun', - month: 2, - day: 3, - startTime: { - hour: 1, - minute: 2, - }, - endTime: { - hour: 3, - minute: 4, - }, - bldg: [], - }, - maxCapacity: 'string', - numCurrentlyEnrolled: { - totalEnrolled: 'string', - sectionEnrolled: 'string', - }, - numOnWaitlist: 'string', - numWaitlistCap: 'string', - numRequested: 'string', - numNewOnlyReserved: 'string', - restrictions: 'string', - status: 'OPEN', - sectionComment: 'string', - }, - term: 'string', - }, - ]; - - const customEvents: RepeatingCustomEvent[] = [ - { - title: 'title', - start: '01:02', - end: '03:04', - days: [true, false, true, false, true, false, true], - customEventID: 0, - color: '#000000', - }, - ]; - - test('calendarizeCourseEvents', () => { - const newResult = calendarizeCourseEvents(courses); - const oldResult = oldCalendarizeCourseEvents(courses); - expect(newResult).toStrictEqual(oldResult); - }); - - test('calendarizeFinals', () => { - const newResult = calendarizeFinals(courses); - const oldResult = oldCalendarizeFinals(courses); - expect(newResult).toStrictEqual(oldResult); - }); - - test('calendarizeCustomEvents', () => { - const newResult = calendarizeCustomEvents(customEvents); - const oldResult = oldClendarizeCustomEvents(customEvents); - expect(newResult).toStrictEqual(oldResult); - }); -}); - -/** - * TODO: Remove this and replace with an array of expected values. - */ -export function oldCalendarizeCourseEvents(currentCourses: ScheduleCourse[] = []): CourseEvent[] { - const courseEventsInCalendar: CourseEvent[] = []; - - for (const course of currentCourses) { - for (const meeting of course.section.meetings) { - if (!meeting.timeIsTBA && meeting.startTime && meeting.endTime && meeting.days) { - const startHour = meeting.startTime.hour; - const startMin = meeting.startTime.minute; - const endHour = meeting.endTime.hour; - const endMin = meeting.endTime.minute; - - const dates: boolean[] = [ - meeting.days.includes('Su'), - meeting.days.includes('M'), - meeting.days.includes('Tu'), - meeting.days.includes('W'), - meeting.days.includes('Th'), - meeting.days.includes('F'), - meeting.days.includes('Sa'), - ]; - - dates.forEach((shouldBeInCal, index) => { - if (shouldBeInCal) { - courseEventsInCalendar.push({ - color: course.section.color, - term: course.term, - title: course.deptCode + ' ' + course.courseNumber, - courseTitle: course.courseTitle, - bldg: meeting.bldg[0], - instructors: course.section.instructors, - sectionCode: course.section.sectionCode, - sectionType: course.section.sectionType, - start: new Date(2018, 0, index, startHour, startMin), - end: new Date(2018, 0, index, endHour, endMin), - finalExam: course.section.finalExam, - isCustomEvent: false as const, - }); - } - }); - } - } - } - - return courseEventsInCalendar; -} - -/** - * TODO: Remove this and replace with an array of expected values. - */ -export function oldCalendarizeFinals(currentCourses: ScheduleCourse[] = []): CourseEvent[] { - const finalsEventsInCalendar: CourseEvent[] = []; - - for (const course of currentCourses) { - const finalExam = course.section.finalExam; - - if ( - finalExam.examStatus == 'SCHEDULED_FINAL' && - finalExam.startTime && - finalExam.endTime && - finalExam.dayOfWeek - ) { - // TODO: this block is almost the same as in calenarizeCourseEvents. we should refactor to remove the duplicate code. - - const startHour = finalExam.startTime.hour; - const startMin = finalExam.startTime.minute; - const endHour = finalExam.endTime.hour; - const endMin = finalExam.endTime.minute; - - const weekdayInclusion: boolean[] = [ - finalExam.dayOfWeek.includes('Sat'), - finalExam.dayOfWeek.includes('Sun'), - finalExam.dayOfWeek.includes('Mon'), - finalExam.dayOfWeek.includes('Tue'), - finalExam.dayOfWeek.includes('Wed'), - finalExam.dayOfWeek.includes('Thu'), - finalExam.dayOfWeek.includes('Fri'), - ]; - - weekdayInclusion.forEach((shouldBeInCal, index) => { - if (shouldBeInCal) - finalsEventsInCalendar.push({ - color: course.section.color, - term: course.term, - title: course.deptCode + ' ' + course.courseNumber, - courseTitle: course.courseTitle, - bldg: course.section.meetings[0].bldg[0], - instructors: course.section.instructors, - sectionCode: course.section.sectionCode, - sectionType: 'Fin', - start: new Date(2018, 0, index - 1, startHour, startMin), - end: new Date(2018, 0, index - 1, endHour, endMin), - finalExam: course.section.finalExam, - isCustomEvent: false, - }); - }); - } - } - - return finalsEventsInCalendar; -} - -/** - * TODO: Remove this and replace with an array of expected values. - */ -export function oldClendarizeCustomEvents(currentCustomEvents: RepeatingCustomEvent[] = []): CustomEvent[] { - const customEventsInCalendar: CustomEvent[] = []; - for (const customEvent of currentCustomEvents) { - for (let dayIndex = 0; dayIndex < customEvent.days.length; dayIndex++) { - if (customEvent.days[dayIndex]) { - const startHour = parseInt(customEvent.start.slice(0, 2), 10); - const startMin = parseInt(customEvent.start.slice(3, 5), 10); - const endHour = parseInt(customEvent.end.slice(0, 2), 10); - const endMin = parseInt(customEvent.end.slice(3, 5), 10); - customEventsInCalendar.push({ - customEventID: customEvent.customEventID, - color: customEvent.color ?? '#000000', - start: new Date(2018, 0, dayIndex, startHour, startMin), - isCustomEvent: true, - end: new Date(2018, 0, dayIndex, endHour, endMin), - title: customEvent.title, - }); - } - } - } - return customEventsInCalendar; -} diff --git a/apps/antalmanac/tsconfig.json b/apps/antalmanac/tsconfig.json index 498a6c3a7..5f82b8e52 100644 --- a/apps/antalmanac/tsconfig.json +++ b/apps/antalmanac/tsconfig.json @@ -22,7 +22,7 @@ "$lib/*": ["src/lib/*"], "$providers/*": ["src/providers/*"], "$routes/*": ["src/routes/*"], - "$stores/*": ["src/stores/*"] + "$stores/*": ["src/stores/*"], } - } + }, } diff --git a/packages/peterportal-schemas/src/websoc.ts b/packages/peterportal-schemas/src/websoc.ts index 3551f7e74..ffa3de248 100644 --- a/packages/peterportal-schemas/src/websoc.ts +++ b/packages/peterportal-schemas/src/websoc.ts @@ -1,98 +1,81 @@ -import { type Infer, arrayOf, type, union } from 'arktype'; -import { type Quarter, quarters } from 'peterportal-api-next-types'; -import enumerate from './enumerate'; - -export const HourMinute = type({ - hour: 'number', - minute: 'number', -}); +import { type Infer, arrayOf, type } from "arktype"; +import { type Quarter, quarters } from "peterportal-api-next-types"; +import enumerate from "./enumerate"; export const WebsocSectionMeeting = type({ - timeIsTBA: 'boolean', - bldg: 'string[]', - days: 'string | null', - startTime: union(HourMinute, 'null'), - endTime: union(HourMinute, 'null'), + days: "string", + time: "string", + bldg: "string[]", }); export const WebsocSectionEnrollment = type({ - totalEnrolled: 'string', - sectionEnrolled: 'string', -}); - -export const WebSocSectionFinals = type({ - examStatus: '"NO_FINAL" | "TBA_FINAL" | "SCHEDULED_FINAL"', - dayOfWeek: '"Sun" | "Mon" | "Tue" | "Wed" | "Thu" | "Fri" | "Sat" | null', - month: 'number | null', - day: 'number | null', - startTime: union(HourMinute, 'null'), - endTime: union(HourMinute, 'null'), - bldg: 'string[] | null', + totalEnrolled: "string", + sectionEnrolled: "string", }); export const WebsocSection = type({ - sectionCode: 'string', - sectionType: 'string', - sectionNum: 'string', - units: 'string', - instructors: 'string[]', - meetings: arrayOf(WebsocSectionMeeting), - finalExam: WebSocSectionFinals, - maxCapacity: 'string', - numCurrentlyEnrolled: WebsocSectionEnrollment, - numOnWaitlist: 'string', - numWaitlistCap: 'string', - numRequested: 'string', - numNewOnlyReserved: 'string', - restrictions: 'string', - status: enumerate(['OPEN', 'Waitl', 'FULL', 'NewOnly'] as const), - sectionComment: 'string', + sectionCode: "string", + sectionType: "string", + sectionNum: "string", + units: "string", + instructors: "string[]", + meetings: arrayOf(WebsocSectionMeeting), + finalExam: "string", + maxCapacity: "string", + numCurrentlyEnrolled: WebsocSectionEnrollment, + numOnWaitlist: "string", + numWaitlistCap: "string", + numRequested: "string", + numNewOnlyReserved: "string", + restrictions: "string", + status: enumerate(["OPEN", "Waitl", "FULL", "NewOnly"] as const), + sectionComment: "string", }); export const WebsocCourse = type({ - deptCode: 'string', - courseNumber: 'string', - courseTitle: 'string', - courseComment: 'string', - prerequisiteLink: 'string', - // sections: arrayOf(WebsocSection), - // Commenting out sections because I don't know how to override this property + deptCode: "string", + courseNumber: "string", + courseTitle: "string", + courseComment: "string", + prerequisiteLink: "string", + // sections: arrayOf(WebsocSection), + // Commenting out sections because I don't know how to override this property }); export const WebsocDepartment = type({ - deptName: 'string', - deptCode: 'string', - deptComment: 'string', - courses: arrayOf(WebsocCourse), - sectionCodeRangeComments: 'string[]', - courseNumberRangeComments: 'string[]', + deptName: "string", + deptCode: "string", + deptComment: "string", + courses: arrayOf(WebsocCourse), + sectionCodeRangeComments: "string[]", + courseNumberRangeComments: "string[]", }); export const WebsocSchool = type({ - schoolName: 'string', - schoolComment: 'string', - departments: arrayOf(WebsocDepartment), + schoolName: "string", + schoolComment: "string", + departments: arrayOf(WebsocDepartment), }); export const Term = type({ - year: 'string', - quarter: enumerate(quarters), + year: "string", + quarter: enumerate(quarters), }); export const WebsocAPIResponse = { - schools: arrayOf(WebsocSchool), + schools: arrayOf(WebsocSchool), }; export const Department = type({ - deptLabel: 'string', - deptValue: 'string', + deptLabel: "string", + deptValue: "string", }); export const DepartmentResponse = arrayOf(Department); export const TermData = type({ - shortName: 'string' as Infer<`${string} ${Quarter}`>, - longName: 'string', + shortName: "string" as Infer<`${string} ${Quarter}`>, + longName: "string", }); export const TermResponse = arrayOf(TermData); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 08f4836c2..a741100e9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -238,8 +238,8 @@ importers: specifier: ^13.1.1 version: 13.1.2 peterportal-api-next-types: - specifier: 1.0.0-rc.2.68.0 - version: 1.0.0-rc.2.68.0 + specifier: 1.0.0-beta.2 + version: 1.0.0-beta.2 prettier: specifier: ^2.8.4 version: 2.8.4 @@ -6743,8 +6743,8 @@ packages: resolution: {integrity: sha512-sbQmYiH21t6wIsgFXStJcBZWhMOCjKQspLGdpUEmpYQaR4tL1kwGQ+KNix5EwLJxHM9BnMtK1BJcwu6fOTeqMQ==} dev: false - /peterportal-api-next-types@1.0.0-rc.2.68.0: - resolution: {integrity: sha512-gq0k53abt6ea9roA+GlSgP3Rbv+0tC4rGw4gGbrahh+ZNnmTGdlZSF8ISq07DbQ7td8dBev4gMrjrZq+Xn500A==} + /peterportal-api-next-types@1.0.0-beta.2: + resolution: {integrity: sha512-y7uFk8nYmOQ9oWxFMCztUoIIDU5eFAfWct92HPGEbIKvTJTDBeNTb57/5qQP0kq0QE0n26dtmLMADvLkbN7P1Q==} dev: true /picocolors@1.0.0: From b43d6f0d23c4a829438acf4b3e5204f5b426875e Mon Sep 17 00:00:00 2001 From: Kevin Wu Date: Sat, 26 Aug 2023 23:31:01 -0700 Subject: [PATCH 26/89] Change "Copy Section Code" to Chip (#668) * Convert button to Chip component * Refactor clickToCopy to async --- .../Calendar/CourseCalendarEvent.tsx | 21 ++++++++++--------- apps/antalmanac/src/lib/helpers.ts | 4 ++-- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/apps/antalmanac/src/components/Calendar/CourseCalendarEvent.tsx b/apps/antalmanac/src/components/Calendar/CourseCalendarEvent.tsx index 74fd4a176..52eded63b 100644 --- a/apps/antalmanac/src/components/Calendar/CourseCalendarEvent.tsx +++ b/apps/antalmanac/src/components/Calendar/CourseCalendarEvent.tsx @@ -1,5 +1,5 @@ import { Link } from 'react-router-dom'; -import { Button, IconButton, Paper, Tooltip } from '@material-ui/core'; +import { Chip, IconButton, Paper, Tooltip } from '@material-ui/core'; import { Theme, withStyles } from '@material-ui/core/styles'; import { ClassNameMap, Styles } from '@material-ui/core/styles/withStyles'; import { Delete } from '@material-ui/icons'; @@ -38,6 +38,7 @@ const styles: Styles = { display: 'flex', justifyContent: 'space-between', alignItems: 'center', + marginBottom: '0.25rem', }, table: { border: 'none', @@ -170,18 +171,18 @@ const CourseCalendarEvent = (props: CourseCalendarEventProps) => { diff --git a/apps/antalmanac/src/lib/helpers.ts b/apps/antalmanac/src/lib/helpers.ts index 67d400cd9..021535377 100644 --- a/apps/antalmanac/src/lib/helpers.ts +++ b/apps/antalmanac/src/lib/helpers.ts @@ -357,9 +357,9 @@ export const warnMultipleTerms = (terms: Set) => { ); }; -export function clickToCopy(event: React.MouseEvent, sectionCode: string) { +export async function clickToCopy(event: React.MouseEvent, sectionCode: string) { event.stopPropagation(); - void navigator.clipboard.writeText(sectionCode); + await navigator.clipboard.writeText(sectionCode); openSnackbar('success', 'WebsocSection code copied to clipboard'); } From 55057e81262aac7dce3387d8cc5c5d744f5c15b2 Mon Sep 17 00:00:00 2001 From: Kevin Wu Date: Sun, 27 Aug 2023 00:40:38 -0700 Subject: [PATCH 27/89] Change 'boolean' visit value to string (#657) --- apps/antalmanac/src/components/PatchNotes.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/antalmanac/src/components/PatchNotes.tsx b/apps/antalmanac/src/components/PatchNotes.tsx index 3b37d2b0e..7f2dded54 100644 --- a/apps/antalmanac/src/components/PatchNotes.tsx +++ b/apps/antalmanac/src/components/PatchNotes.tsx @@ -5,12 +5,15 @@ import { useEffect, useState } from 'react'; const PatchNotes = () => { const [isOpen, setIsOpen] = useState(true); - // show modal only on first visit + // show modal only if the current patch notes haven't been shown + // This is denoted by a date string YYYYMMDD (e.g. 20230819) + const latestPatchNotesUpdate = '20230819'; + useEffect(() => { - if (localStorage.getItem('visitedCount') == 'y') { + if (localStorage.getItem('latestVisit') == latestPatchNotesUpdate) { setIsOpen(false); } else { - localStorage.setItem('visitedCount', 'y'); + localStorage.setItem('latestVisit', latestPatchNotesUpdate); } }, []); From 03098982af77052ea5fe6a29cbb3d2f24c543db5 Mon Sep 17 00:00:00 2001 From: Kevin Wu Date: Tue, 29 Aug 2023 19:25:29 -0700 Subject: [PATCH 28/89] Add Course Level Filtering, Restyle Advanced Search (#677) * Add support for division * Completely revert #598 * Implement Select element * Move label to top * Shorten division Select, Select styling to GE * Restyle AdvancedSearch * Adjust anchor of Select components --- .../RightPane/CoursePane/CourseRenderPane.tsx | 1 + .../CoursePane/SearchForm/AdvancedSearch.tsx | 73 ++++++++++++-- .../SearchForm/CourseNumberSearchBar.tsx | 50 ++-------- .../CoursePane/SearchForm/GESelector.tsx | 22 ++++- .../components/RightPane/RightPaneStore.ts | 1 + apps/antalmanac/src/lib/helpers.ts | 6 ++ packages/peterportal-schemas/src/websoc.ts | 94 +++++++++---------- 7 files changed, 146 insertions(+), 101 deletions(-) diff --git a/apps/antalmanac/src/components/RightPane/CoursePane/CourseRenderPane.tsx b/apps/antalmanac/src/components/RightPane/CoursePane/CourseRenderPane.tsx index 3cd26a113..ebdc7f647 100644 --- a/apps/antalmanac/src/components/RightPane/CoursePane/CourseRenderPane.tsx +++ b/apps/antalmanac/src/components/RightPane/CoursePane/CourseRenderPane.tsx @@ -227,6 +227,7 @@ class CourseRenderPane extends PureComponent = { + fieldContainer: { + display: 'flex', + gap: '1.5rem', + flexWrap: 'wrap', + paddingLeft: '8px', + paddingRight: '8px', + marginBottom: '1rem', + }, units: { width: '80px', }, timePicker: { width: '130px', }, - smallTextFields: { - display: 'flex', - justifyContent: 'space-around', - flexWrap: 'wrap', + onlineSwitch: { + margin: 0, + justifyContent: 'flex-end', + left: 0, }, }; @@ -44,6 +53,7 @@ interface AdvancedSearchTextFieldsState { coursesFull: string; building: string; room: string; + division: string; } interface AdvancedSearchProps { @@ -66,6 +76,7 @@ class UnstyledAdvancedSearchTextFields extends PureComponent< coursesFull: RightPaneStore.getFormData().coursesFull, building: RightPaneStore.getFormData().building, room: RightPaneStore.getFormData().room, + division: RightPaneStore.getFormData().division, }; componentDidMount() { @@ -85,6 +96,7 @@ class UnstyledAdvancedSearchTextFields extends PureComponent< coursesFull: RightPaneStore.getFormData().coursesFull, building: RightPaneStore.getFormData().building, room: RightPaneStore.getFormData().room, + division: RightPaneStore.getFormData().division, }); }; @@ -129,7 +141,7 @@ class UnstyledAdvancedSearchTextFields extends PureComponent< const endsBeforeMenuItems = ['', ...menuItemTimes].map((time) => createdMenuItemTime(time)); return ( -
+ Class Full Option - Include all classes Include full courses if space on waitlist Skip full courses @@ -159,6 +185,35 @@ class UnstyledAdvancedSearchTextFields extends PureComponent< + + + Course Level + + + + Starts After + - {scheduleNames.map((name, index) => ( - - {name} - - ))} - setOpenSchedules(true)} - onClose={() => setOpenSchedules(false)} - scheduleNames={scheduleNames} - /> - - - - - - -
- - - { - logAnalytics({ - category: analyticsEnum.calendar.title, - action: analyticsEnum.calendar.actions.UNDO, - }); - undoDelete(null); - }} - > - - - - - - { - if (window.confirm('Are you sure you want to clear this schedule?')) { - clearSchedules(); - logAnalytics({ - category: analyticsEnum.calendar.title, - action: analyticsEnum.calendar.actions.CLEAR_SCHEDULE, - }); - } - }} - > - - - - - {isMobileScreen ? ( -
- - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {isMobileScreen ? ( + + + + + + + + + + + + + + + + + ) : ( + + + + + + )} + ); }; -export default withStyles(styles)(CalendarPaneToolbar); +export default CalendarPaneToolbar; diff --git a/apps/antalmanac/src/components/Calendar/Toolbar/EditSchedule/ScheduleNameDialog.tsx b/apps/antalmanac/src/components/Calendar/Toolbar/EditSchedule/ScheduleNameDialog.tsx index 377ec09ae..2dad5b088 100644 --- a/apps/antalmanac/src/components/Calendar/Toolbar/EditSchedule/ScheduleNameDialog.tsx +++ b/apps/antalmanac/src/components/Calendar/Toolbar/EditSchedule/ScheduleNameDialog.tsx @@ -1,8 +1,8 @@ +import { forwardRef, useCallback, useState, useMemo } from 'react'; import { Button, Dialog, DialogActions, DialogContent, DialogTitle, MenuItem, TextField } from '@material-ui/core'; import { withStyles } from '@material-ui/core/styles'; import { ClassNameMap } from '@material-ui/core/styles/withStyles'; import { Add } from '@material-ui/icons'; -import React, { forwardRef, useState } from 'react'; import { addSchedule, renameSchedule } from '$actions/AppStoreActions'; import { isDarkMode } from '$lib/helpers'; @@ -19,58 +19,74 @@ const styles = () => ({ interface ScheduleNameDialogProps { classes: ClassNameMap; onOpen?: () => void; - onClose: () => void; + onClose?: () => void; scheduleNames: string[]; scheduleRenameIndex?: number; } const ScheduleNameDialog = forwardRef((props: ScheduleNameDialogProps, ref) => { const { classes, onOpen, onClose, scheduleNames, scheduleRenameIndex } = props; - const rename = scheduleRenameIndex !== undefined; const [isOpen, setIsOpen] = useState(false); + const [scheduleName, setScheduleName] = useState( scheduleRenameIndex !== undefined ? scheduleNames[scheduleRenameIndex] : `Schedule ${scheduleNames.length + 1}` ); - const handleOpen: React.MouseEventHandler = (event) => { - // We need to stop propagation so that the select menu won't close - event.stopPropagation(); - setIsOpen(true); - if (onOpen) { - onOpen(); - } - }; + const rename = useMemo(() => scheduleRenameIndex !== undefined, [scheduleRenameIndex]); + + // We need to stop propagation so that the select menu won't close + const handleOpen = useCallback( + (event: React.MouseEvent) => { + event.stopPropagation(); + setIsOpen(true); + onOpen?.(); + }, + [onOpen] + ); - const handleCancel = () => { + /** + * If the user cancelled renaming the schedule, the schedule name is changed to its original value. + * If the user cancelled adding a new schedule, the schedule name is changed to the default schedule name. + */ + const handleCancel = useCallback(() => { setIsOpen(false); - // If the user cancelled renaming the schedule, the schedule name is changed to its original value; - // if the user cancelled adding a new schedule, the schedule name is changed to the default schedule name - setScheduleName(rename ? scheduleNames[scheduleRenameIndex] : `Schedule ${scheduleNames.length + 1}`); - }; - const handleNameChange = (event: React.ChangeEvent) => { + if (scheduleRenameIndex != null) { + setScheduleName(rename ? scheduleNames[scheduleRenameIndex] : `Schedule ${scheduleNames.length + 1}`); + } + }, [rename, scheduleNames, scheduleRenameIndex]); + + const handleNameChange = useCallback((event: React.ChangeEvent) => { setScheduleName(event.target.value); - }; + }, []); - const handleKeyDown = (event: React.KeyboardEvent) => { - event.stopPropagation(); - if (event.key === 'Enter') { - submitName(); - } - if (event.key === 'Escape') { - setIsOpen(false); - } - }; + const submitName = useCallback(() => { + onClose?.(); - const submitName = () => { - onClose(); if (rename) { renameSchedule(scheduleName, scheduleRenameIndex as number); // typecast works b/c this function only runs when `const rename = scheduleRenameIndex !== undefined` is true. } else { addSchedule(scheduleName); } - }; + + setIsOpen(false); + }, [onClose, rename, scheduleName, scheduleRenameIndex]); + + const handleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + event.stopPropagation(); + + if (event.key === 'Enter') { + submitName(); + } + + if (event.key === 'Escape') { + setIsOpen(false); + } + }, + [submitName] + ); // For the dialog, we need to stop the propagation when a key is pressed because // MUI Select components support "select by typing", which can remove focus from the dialog. @@ -95,6 +111,7 @@ const ScheduleNameDialog = forwardRef((props: ScheduleNameDialogProps, ref) => { onClose={() => setIsOpen(false)} > {rename ? 'Rename Schedule' : 'Add a New Schedule'} + { value={scheduleName} /> +
} /> - Search
} /> + Classes
} /> {components[selectedTab]} diff --git a/apps/antalmanac/src/components/RightPane/RightPaneRoot.tsx b/apps/antalmanac/src/components/RightPane/RightPaneRoot.tsx index 9d8d1d0cd..982372b0c 100644 --- a/apps/antalmanac/src/components/RightPane/RightPaneRoot.tsx +++ b/apps/antalmanac/src/components/RightPane/RightPaneRoot.tsx @@ -33,12 +33,12 @@ export default function Desktop({ style }: DesktopTabsProps) { const tabs = [ { - label: 'Class Search', + label: 'Search', href: '/', icon: Search, }, { - label: 'Added Courses', + label: 'Added', href: '/added', icon: FormatListBulleted, }, From 28557659476332327d43a01e06906cdd75bcf2f0 Mon Sep 17 00:00:00 2001 From: Kevin Wu Date: Sun, 3 Sep 2023 21:08:00 -0700 Subject: [PATCH 31/89] Add support for new API time formats (#669) (#679) Co-authored-by: Aponia Co-authored-by: Eric Pedley --- README.md | 4 + apps/antalmanac/package.json | 2 +- .../Calendar/CourseCalendarEvent.tsx | 38 +- .../SectionTable/SectionTableBody.tsx | 17 +- .../SectionTable/SectionTableButtons.tsx | 2 +- apps/antalmanac/src/lib/api/endpoints.ts | 5 +- apps/antalmanac/src/lib/utils.ts | 30 ++ .../src/stores/calendarizeHelpers.ts | 342 +++++++++--------- .../tests/calendarize-helpers.test.ts | 247 +++++++++++++ apps/antalmanac/tsconfig.json | 4 +- packages/peterportal-schemas/src/websoc.ts | 25 +- pnpm-lock.yaml | 8 +- 12 files changed, 537 insertions(+), 187 deletions(-) create mode 100644 apps/antalmanac/src/lib/utils.ts create mode 100644 apps/antalmanac/tests/calendarize-helpers.test.ts diff --git a/README.md b/README.md index aa9a8bbc7..50a05c8e9 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,10 @@ If you ever need help, feel free to ask around on our [Discord server](https://d and the backend server at http://localhost:8080 (if started). As you make changes to the React application in `src`, those changes will be automatically reflected on the website locally. +#### Running Tests +1. go into `apps/antalmanac` +2. `pnpm run test` + ### Running the [Backend](https://github.com/icssc/antalmanac-backend) The backend server __isn't necessary for frontend development__. diff --git a/apps/antalmanac/package.json b/apps/antalmanac/package.json index ae38c8cc4..39fa48f3d 100644 --- a/apps/antalmanac/package.json +++ b/apps/antalmanac/package.json @@ -93,7 +93,7 @@ "gh-pages": "^5.0.0", "husky": "^8.0.3", "lint-staged": "^13.1.1", - "peterportal-api-next-types": "1.0.0-beta.2", + "peterportal-api-next-types": "1.0.0-rc.2.68.0", "prettier": "^2.8.4", "typescript": "^4.9.5", "vite": "^4.2.1", diff --git a/apps/antalmanac/src/components/Calendar/CourseCalendarEvent.tsx b/apps/antalmanac/src/components/Calendar/CourseCalendarEvent.tsx index 52eded63b..40263f5e4 100644 --- a/apps/antalmanac/src/components/Calendar/CourseCalendarEvent.tsx +++ b/apps/antalmanac/src/components/Calendar/CourseCalendarEvent.tsx @@ -14,10 +14,12 @@ import { clickToCopy, isDarkMode } from '$lib/helpers'; import AppStore from '$stores/AppStore'; import locationIds from '$lib/location_ids'; import { mobileContext } from '$components/MobileHome'; +import { translate24To12HourTime } from '$stores/calendarizeHelpers'; const styles: Styles = { courseContainer: { padding: '0.5rem', + margin: '0 1rem', minWidth: '15rem', }, customEventContainer: { @@ -88,7 +90,22 @@ interface CommonCalendarEvent extends Event { export interface CourseEvent extends CommonCalendarEvent { bldg: string; // E.g., ICS 174, which is actually building + room - finalExam: string; + finalExam: { + examStatus: 'NO_FINAL' | 'TBA_FINAL' | 'SCHEDULED_FINAL'; + dayOfWeek: 'Sun' | 'Mon' | 'Tue' | 'Wed' | 'Thu' | 'Fri' | 'Sat' | null; + month: number | null; + day: number | null; + startTime: { + hour: number; + minute: number; + } | null; + endTime: { + hour: number; + minute: number; + } | null; + bldg: string[] | null; + }; + courseTitle: string; instructors: string[]; isCustomEvent: false; sectionCode: string; @@ -114,6 +131,8 @@ interface CourseCalendarEventProps { closePopover: () => void; } +const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + const CourseCalendarEvent = (props: CourseCalendarEventProps) => { const paperRef = useRef(null); @@ -146,6 +165,21 @@ const CourseCalendarEvent = (props: CourseCalendarEventProps) => { const buildingId = locationIds[buildingName] ?? 69420; + let finalExamString = ''; + if (finalExam.examStatus == 'NO_FINAL') { + finalExamString = 'No Final'; + } else if (finalExam.examStatus == 'TBA_FINAL') { + finalExamString = 'Final TBA'; + } else { + if (finalExam.startTime && finalExam.endTime && finalExam.month && finalExam.bldg) { + const timeString = translate24To12HourTime(finalExam.startTime, finalExam.endTime); + const locationString = `at ${finalExam.bldg.join(', ')}`; + const finalExamMonth = MONTHS[finalExam.month]; + + finalExamString = `${finalExam.dayOfWeek} ${finalExamMonth} ${finalExam.day} ${timeString} ${locationString}`; + } + } + return (
@@ -208,7 +242,7 @@ const CourseCalendarEvent = (props: CourseCalendarEventProps) => {
- + diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx index b958eea5b..7b680361b 100644 --- a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx +++ b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx @@ -32,7 +32,7 @@ import { clickToCopy, CourseDetails, isDarkMode, queryGrades } from '$lib/helper import AppStore from '$stores/AppStore'; import { mobileContext } from '$components/MobileHome'; import locationIds from '$lib/location_ids'; -import { translateWebSOCTimeTo24HourTime, parseDaysString } from '$stores/calendarizeHelpers'; +import { normalizeTime, parseDaysString, translate24To12HourTime } from '$stores/calendarizeHelpers'; const styles: Styles = (theme) => ({ sectionCode: { @@ -290,7 +290,7 @@ const LocationsCell = withStyles(styles)((props: LocationsCellProps) => { const [buildingName = ''] = meeting.bldg[0].split(' '); const buildingId = locationIds[buildingName]; return meeting.bldg[0] !== 'TBA' ? ( - + { return ( {meetings.map((meeting) => { - const timeString = meeting.time.replace(/\s/g, '').split('-').join(' - '); - return {`${meeting.days} ${timeString}`}; + if (meeting.timeIsTBA) { + return TBA; + } + + if (meeting.startTime && meeting.endTime) { + const timeString = translate24To12HourTime(meeting.startTime, meeting.endTime); + + return {`${meeting.days} ${timeString}`}; + } })} ); @@ -479,7 +486,7 @@ const SectionTableBody = withStyles(styles)((props: SectionTableBodyProps) => { const sectionDetails = useMemo(() => { return { daysOccurring: parseDaysString(section.meetings[0].days), - ...translateWebSOCTimeTo24HourTime(section.meetings[0].time), + ...normalizeTime(section.meetings[0]), }; }, [section.meetings[0]]); diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableButtons.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableButtons.tsx index e00a848f2..951ae796a 100644 --- a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableButtons.tsx +++ b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableButtons.tsx @@ -80,7 +80,7 @@ export const ScheduleAddCell = withStyles(styles)((props: ScheduleAddCellProps) const closeAndAddCourse = (scheduleIndex: number, specificSchedule?: boolean) => { popupState.close(); for (const meeting of section.meetings) { - if (meeting.time === 'TBA') { + if (meeting.timeIsTBA) { openSnackbar('success', 'Online/TBA class added'); // See Added Classes." break; diff --git a/apps/antalmanac/src/lib/api/endpoints.ts b/apps/antalmanac/src/lib/api/endpoints.ts index 888f9ea3a..e381fb1ce 100644 --- a/apps/antalmanac/src/lib/api/endpoints.ts +++ b/apps/antalmanac/src/lib/api/endpoints.ts @@ -17,4 +17,7 @@ export const REGISTER_NOTIFICATIONS_ENDPOINT = endpointTransform('/api/notificat // PeterPortal API export const PETERPORTAL_GRAPHQL_ENDPOINT = 'https://api-next.peterportal.org/v1/graphql'; export const PETERPORTAL_REST_ENDPOINT = 'https://api-next.peterportal.org/v1/rest'; -export const PETERPORTAL_WEBSOC_ENDPOINT = `${PETERPORTAL_REST_ENDPOINT}/websoc`; + +// Testing API +export const PETERPORTAL_REST_ENDPOINT_68 = 'https://staging-68.api-next.peterportal.org/v1/rest'; +export const PETERPORTAL_WEBSOC_ENDPOINT = `${PETERPORTAL_REST_ENDPOINT_68}/websoc`; diff --git a/apps/antalmanac/src/lib/utils.ts b/apps/antalmanac/src/lib/utils.ts new file mode 100644 index 000000000..657c70252 --- /dev/null +++ b/apps/antalmanac/src/lib/utils.ts @@ -0,0 +1,30 @@ +export function notNull(value: T): value is NonNullable { + return value != null; +} + +/** + * Given a reference array and an input, generate an array of booleans for each position in the + * reference array, indicating whether the value at that position is in the input. + * + * @example + * reference = ['a', 'b', 'c', 'd', 'e'] + * input = 'ace' + * result = [true, false, true, false, true] + * + * Can be used in conjunection with {@link notNull} to get only indices. + * + * @example + * + * ```ts + * + * const reference = ['a', 'b', 'c', 'd', 'e']; + * const input = 'ace'; + * + * const occurringReferences = getReferencesOccurring(reference, input) + * const indicesOrNull = occurringReferences.map((occurring, index) => occurring ? index : null) + * const indices = indicesOrNull.filter(notNull) + * ``` + */ +export function getReferencesOccurring(reference: string[], input?: string | string[] | null): boolean[] { + return input ? reference.map((reference) => input.includes(reference)) : reference.map(() => false); +} diff --git a/apps/antalmanac/src/stores/calendarizeHelpers.ts b/apps/antalmanac/src/stores/calendarizeHelpers.ts index 97180517e..0451af34e 100644 --- a/apps/antalmanac/src/stores/calendarizeHelpers.ts +++ b/apps/antalmanac/src/stores/calendarizeHelpers.ts @@ -1,179 +1,128 @@ import { ScheduleCourse } from '@packages/antalmanac-types'; +import { HourMinute } from 'peterportal-api-next-types'; import { CourseEvent, CustomEvent } from '$components/Calendar/CourseCalendarEvent'; import { RepeatingCustomEvent } from '$components/Calendar/Toolbar/CustomEventDialog/CustomEventDialog'; - -export const calendarizeCourseEvents = (currentCourses: ScheduleCourse[] = []) => { - const courseEventsInCalendar: CourseEvent[] = []; - - for (const course of currentCourses) { - for (const meeting of course.section.meetings) { - const timeString = meeting.time.replace(/\s/g, ''); - - if (timeString !== 'TBA') { - const [, startHrStr, startMinStr, endHrStr, endMinStr, ampm] = timeString.match( - /(\d{1,2}):(\d{2})-(\d{1,2}):(\d{2})(p?)/ - ) as RegExpMatchArray; - - let startHr = parseInt(startHrStr, 10); - const startMin = parseInt(startMinStr, 10); - let endHr = parseInt(endHrStr, 10); - const endMin = parseInt(endMinStr, 10); - - const dates = [ - meeting.days.includes('Su'), - meeting.days.includes('M'), - meeting.days.includes('Tu'), - meeting.days.includes('W'), - meeting.days.includes('Th'), - meeting.days.includes('F'), - meeting.days.includes('Sa'), - ]; - - if (ampm === 'p' && endHr !== 12) { - startHr += 12; - endHr += 12; - if (startHr > endHr) startHr -= 12; - } - - dates.forEach((shouldBeInCal, index) => { - if (shouldBeInCal) { - const newEvent = { - color: course.section.color, - term: course.term, - title: course.deptCode + ' ' + course.courseNumber, - courseTitle: course.courseTitle, - bldg: meeting.bldg[0], - instructors: course.section.instructors, - sectionCode: course.section.sectionCode, - sectionType: course.section.sectionType, - start: new Date(2018, 0, index, startHr, startMin), - finalExam: course.section.finalExam, - end: new Date(2018, 0, index, endHr, endMin), - isCustomEvent: false as const, - }; - - courseEventsInCalendar.push(newEvent); - } - }); - } - } - } - - return courseEventsInCalendar; -}; - -export const calendarizeFinals = (currentCourses: ScheduleCourse[] = []) => { - const finalsEventsInCalendar: CourseEvent[] = []; - - for (const course of currentCourses) { - const finalExam = course.section.finalExam; - if (finalExam.length > 5) { - const [, date, , , startStr, startMinStr, endStr, endMinStr, ampm] = finalExam.match( - /([A-za-z]+) ([A-Za-z]+) *(\d{1,2}) *(\d{1,2}):(\d{2})-(\d{1,2}):(\d{2})(am|pm)/ - ) as RegExpMatchArray; - // TODO: this block is almost the same as in calenarizeCourseEvents. we should refactor to remove the duplicate code. - let startHour = parseInt(startStr, 10); - const startMin = parseInt(startMinStr, 10); - let endHour = parseInt(endStr, 10); - const endMin = parseInt(endMinStr, 10); - const weekdayInclusion: boolean[] = [ - date.includes('Sat'), - date.includes('Sun'), - date.includes('Mon'), - date.includes('Tue'), - date.includes('Wed'), - date.includes('Thu'), - date.includes('Fri'), - ]; - if (ampm === 'pm' && endHour !== 12) { - startHour += 12; - endHour += 12; - if (startHour > endHour) startHour -= 12; - } - - weekdayInclusion.forEach((shouldBeInCal, index) => { - if (shouldBeInCal) - finalsEventsInCalendar.push({ - title: course.deptCode + ' ' + course.courseNumber, - sectionCode: course.section.sectionCode, - sectionType: 'Fin', - bldg: course.section.meetings[0].bldg[0], +import { notNull, getReferencesOccurring } from '$lib/utils'; + +const COURSE_WEEK_DAYS = ['Su', 'M', 'Tu', 'W', 'Th', 'F', 'Sa']; + +const FINALS_WEEK_DAYS = ['Sat', 'Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri']; + +export function calendarizeCourseEvents(currentCourses: ScheduleCourse[] = []): CourseEvent[] { + return currentCourses.flatMap((course) => { + return course.section.meetings + .filter((meeting) => !meeting.timeIsTBA && meeting.startTime && meeting.endTime && meeting.days) + .flatMap((meeting) => { + const startHour = meeting.startTime?.hour; + const startMin = meeting.startTime?.minute; + const endHour = meeting.endTime?.hour; + const endMin = meeting.endTime?.minute; + + /** + * An array of booleans indicating whether a course meeting occurs on that day. + * + * @example [false, true, false, true, false, true, false], i.e. [M, W, F] + */ + const daysOccurring = getReferencesOccurring(COURSE_WEEK_DAYS, meeting.days); + + /** + * Only include the day indices that the meeting occurs. + * + * @example [false, true, false, true, false, true, false] -> [1, 3, 5] + */ + const dayIndicesOccurring = daysOccurring + .map((day, index) => (day ? index : undefined)) + .filter(notNull); + + return dayIndicesOccurring.map((dayIndex) => { + return { color: course.section.color, - start: new Date(2018, 0, index - 1, startHour, startMin), - end: new Date(2018, 0, index - 1, endHour, endMin), - finalExam: course.section.finalExam, - instructors: course.section.instructors, term: course.term, + title: `${course.deptCode} ${course.courseNumber}`, + courseTitle: course.courseTitle, + bldg: meeting.bldg[0], + instructors: course.section.instructors, + sectionCode: course.section.sectionCode, + sectionType: course.section.sectionType, + start: new Date(2018, 0, dayIndex, startHour, startMin), + end: new Date(2018, 0, dayIndex, endHour, endMin), + finalExam: course.section.finalExam, isCustomEvent: false, - }); - }); - } - } - - return finalsEventsInCalendar; -}; - -export const calendarizeCustomEvents = (currentCustomEvents: RepeatingCustomEvent[] = []) => { - const customEventsInCalendar: CustomEvent[] = []; - - for (const customEvent of currentCustomEvents) { - for (let dayIndex = 0; dayIndex < customEvent.days.length; dayIndex++) { - if (customEvent.days[dayIndex]) { - const startHour = parseInt(customEvent.start.slice(0, 2), 10); - const startMin = parseInt(customEvent.start.slice(3, 5), 10); - const endHour = parseInt(customEvent.end.slice(0, 2), 10); - const endMin = parseInt(customEvent.end.slice(3, 5), 10); - - customEventsInCalendar.push({ - customEventID: customEvent.customEventID, - color: customEvent.color ?? '#000000', - start: new Date(2018, 0, dayIndex, startHour, startMin), - isCustomEvent: true, - end: new Date(2018, 0, dayIndex, endHour, endMin), - title: customEvent.title, + }; }); - } - } - } - - return customEventsInCalendar; -}; - -interface TranslatedWebSOCTime { - startTime: string; - endTime: string; + }); + }); } -/** - * @param time The time string. - * @returns The start and end time of a course in a 24 hour time with a leading zero (##:##). - * @returns undefined if there is no WebSOC time (e.g. 'TBA', undefined) - */ -export function translateWebSOCTimeTo24HourTime(time: string): TranslatedWebSOCTime | undefined { - const timeString = time.replace(/\s/g, ''); - - if (timeString !== 'TBA' && timeString !== undefined) { - const [, startHrStr, startMinStr, endHrStr, endMinStr, ampm] = timeString.match( - /(\d{1,2}):(\d{2})-(\d{1,2}):(\d{2})(p?)/ - ) as RegExpMatchArray; - - let startHr = parseInt(startHrStr, 10); - let endHr = parseInt(endHrStr, 10); - - if (ampm === 'p' && endHr !== 12) { - startHr += 12; - endHr += 12; - if (startHr > endHr) startHr -= 12; - } - - // Times are standardized to ##:## (i.e. leading zero) for correct comparisons as strings - return { - startTime: `${startHr < 10 ? `0${startHr}` : startHr}:${startMinStr}`, - endTime: `${endHr < 10 ? `0${endHr}` : endHr}:${endMinStr}`, - }; - } +export function calendarizeFinals(currentCourses: ScheduleCourse[] = []): CourseEvent[] { + return currentCourses + .filter( + (course) => + course.section.finalExam.examStatus === 'SCHEDULED_FINAL' && + course.section.finalExam.startTime && + course.section.finalExam.endTime && + course.section.finalExam.dayOfWeek + ) + .flatMap((course) => { + const finalExam = course.section.finalExam; + const startHour = finalExam.startTime?.hour; + const startMin = finalExam.startTime?.minute; + const endHour = finalExam.endTime?.hour; + const endMin = finalExam.endTime?.minute; + + /** + * An array of booleans indicating whether the day at that index is a day that the final. + * + * @example [false, false, false, true, false, true, false], i.e. [T, Th] + */ + const weekdaysOccurring = getReferencesOccurring(FINALS_WEEK_DAYS, course.section.finalExam.dayOfWeek); + + /** + * Only include the day indices that the final is occurring. + * + * @example [false, false, false, true, false, true, false] -> [3, 5] + */ + const dayIndicesOcurring = weekdaysOccurring.map((day, index) => (day ? index : undefined)).filter(notNull); + + return dayIndicesOcurring.map((dayIndex) => { + return { + color: course.section.color, + term: course.term, + title: `${course.deptCode} ${course.courseNumber}`, + courseTitle: course.courseTitle, + bldg: course.section.meetings[0].bldg[0], + instructors: course.section.instructors, + sectionCode: course.section.sectionCode, + sectionType: 'Fin', + start: new Date(2018, 0, dayIndex - 1, startHour, startMin), + end: new Date(2018, 0, dayIndex - 1, endHour, endMin), + finalExam: course.section.finalExam, + isCustomEvent: false, + }; + }); + }); +} - return undefined; +export function calendarizeCustomEvents(currentCustomEvents: RepeatingCustomEvent[] = []): CustomEvent[] { + return currentCustomEvents.flatMap((customEvent) => { + const dayIndiciesOcurring = customEvent.days.map((day, index) => (day ? index : undefined)).filter(notNull); + + return dayIndiciesOcurring.map((dayIndex) => { + const startHour = parseInt(customEvent.start.slice(0, 2), 10); + const startMin = parseInt(customEvent.start.slice(3, 5), 10); + const endHour = parseInt(customEvent.end.slice(0, 2), 10); + const endMin = parseInt(customEvent.end.slice(3, 5), 10); + + return { + customEventID: customEvent.customEventID, + color: customEvent.color ?? '#000000', + start: new Date(2018, 0, dayIndex, startHour, startMin), + isCustomEvent: true, + end: new Date(2018, 0, dayIndex, endHour, endMin), + title: customEvent.title, + }; + }); + }); } export const SHORT_DAYS = ['Su', 'M', 'Tu', 'W', 'Th', 'F', 'Sa']; @@ -189,7 +138,11 @@ export const SHORT_DAY_REGEX = new RegExp(`(${SHORT_DAYS.join('|')})`, 'g'); * @example 'TuTh' -> [2, 4] * @example 'MWFTh' -> [1, 3, 5, 4] */ -export function parseDaysString(daysString: string): number[] { +export function parseDaysString(daysString: string | null): number[] | null { + if (daysString == null) { + return null; + } + const days: number[] = []; let match: RegExpExecArray | null; @@ -200,3 +153,58 @@ export function parseDaysString(daysString: string): number[] { return days; } + +interface NormalizedWebSOCTime { + startTime: string; + endTime: string; +} + +/** + * @param section + * @returns The start and end time of a course in a 24 hour time with a leading zero (##:##). + * @returns undefined if there is no WebSOC time (e.g. 'TBA', undefined) + */ +interface NormalizeTimeOptions { + timeIsTBA?: boolean; + startTime?: HourMinute | null; + endTime?: HourMinute | null; +} + +/** + * @param section + * @returns The start and end time of a course in a 24 hour time with a leading zero (##:##). + * @returns undefined if there is no WebSOC time (e.g. 'TBA', undefined) + */ +export function normalizeTime(options: NormalizeTimeOptions): NormalizedWebSOCTime | undefined { + if (options.timeIsTBA || !options.startTime || !options.endTime) { + return; + } + + // Times are normalized to ##:## (10:00, 09:00 etc) + const startHour = `${options.startTime.hour}`.padStart(2, '0'); + const endHour = `${options.endTime.hour}`.padStart(2, '0'); + + const startTime = `${startHour}:${options.startTime.minute}`; + const endTime = `${endHour}:${options.endTime.minute}`; + + return { startTime, endTime }; +} + +export function translate24To12HourTime(startTime?: HourMinute, endTime?: HourMinute): string | undefined { + if (!startTime || !endTime) { + return; + } + + const timeSuffix = endTime.hour >= 12 ? 'PM' : 'AM'; + + const formattedStartHour = `${startTime.hour > 12 ? startTime.hour - 12 : startTime.hour}`; + const formattedEndHour = `${endTime.hour > 12 ? endTime.hour - 12 : endTime.hour}`; + + const formattedStartMinute = `${startTime.minute}`; + const formattedEndMinute = `${endTime.minute}`; + + const meetingStartTime = `${formattedStartHour}:${formattedStartMinute.padStart(2, '0')}`; + const meetingEndTime = `${formattedEndHour}:${formattedEndMinute.padStart(2, '0')}`; + + return `${meetingStartTime} - ${meetingEndTime} ${timeSuffix}`; +} diff --git a/apps/antalmanac/tests/calendarize-helpers.test.ts b/apps/antalmanac/tests/calendarize-helpers.test.ts new file mode 100644 index 000000000..4a2e5d5ff --- /dev/null +++ b/apps/antalmanac/tests/calendarize-helpers.test.ts @@ -0,0 +1,247 @@ +import { describe, test, expect } from 'vitest'; +import type { ScheduleCourse } from '@packages/antalmanac-types'; +import type { RepeatingCustomEvent } from '$components/Calendar/Toolbar/CustomEventDialog/CustomEventDialog'; +import { calendarizeCourseEvents, calendarizeCustomEvents, calendarizeFinals } from '$stores/calendarizeHelpers'; + +describe('calendarize-helpers', () => { + const courses: ScheduleCourse[] = [ + { + courseComment: 'placeholderCourseComment', + courseNumber: 'placeholderCourseNumber', + courseTitle: 'placeholderCourseTitle', + deptCode: 'placeholderDeptCode', + prerequisiteLink: 'placeholderPrerequisiteLink', + section: { + color: 'placeholderColor', + sectionCode: 'placeholderSectionCode', + sectionType: 'placeholderSectionType', + sectionNum: 'placeholderSectionNum', + units: 'placeholderUnits', + instructors: [], + meetings: [ + { + timeIsTBA: false, + bldg: [], + days: 'MWF', + startTime: { + hour: 1, + minute: 2, + }, + endTime: { + hour: 3, + minute: 4, + }, + }, + ], + finalExam: { + examStatus: 'SCHEDULED_FINAL', + dayOfWeek: 'Sun', + month: 2, + day: 3, + startTime: { + hour: 1, + minute: 2, + }, + endTime: { + hour: 3, + minute: 4, + }, + bldg: [], + }, + maxCapacity: 'placeholderMaxCapacity', + numCurrentlyEnrolled: { + totalEnrolled: 'placeholderTotalEnrolled', + sectionEnrolled: 'placeholderSectionEnrolled', + }, + numOnWaitlist: 'placeholderNumOnWaitlist', + numWaitlistCap: 'placeholderNumWaitlistCap', + numRequested: 'placeholderNumRequested', + numNewOnlyReserved: 'placeholderNumNewOnlyReserved', + restrictions: 'placeholderRestrictions', + status: 'OPEN', + sectionComment: 'placeholderSectionComment', + }, + term: 'placeholderTerm', + }, + ]; + + // 3 of the same event + const calendarizedCourses = [ + { + bldg: undefined, + color: 'placeholderColor', + term: 'placeholderTerm', + title: 'placeholderDeptCode placeholderCourseNumber', + courseTitle: 'placeholderCourseTitle', + instructors: [], + sectionCode: 'placeholderSectionCode', + sectionType: 'placeholderSectionType', + start: new Date(2018, 0, 1, 1, 2), + end: new Date(2018, 0, 1, 3, 4), + finalExam: { + examStatus: 'SCHEDULED_FINAL', + dayOfWeek: 'Sun', + month: 2, + day: 3, + startTime: { + hour: 1, + minute: 2, + }, + endTime: { + hour: 3, + minute: 4, + }, + bldg: [], + }, + isCustomEvent: false, + }, + { + bldg: undefined, + color: 'placeholderColor', + term: 'placeholderTerm', + title: 'placeholderDeptCode placeholderCourseNumber', + courseTitle: 'placeholderCourseTitle', + instructors: [], + sectionCode: 'placeholderSectionCode', + sectionType: 'placeholderSectionType', + start: new Date(2018, 0, 3, 1, 2), + end: new Date(2018, 0, 3, 3, 4), + finalExam: { + examStatus: 'SCHEDULED_FINAL', + dayOfWeek: 'Sun', + month: 2, + day: 3, + startTime: { + hour: 1, + minute: 2, + }, + endTime: { + hour: 3, + minute: 4, + }, + bldg: [], + }, + isCustomEvent: false, + }, + { + bldg: undefined, + color: 'placeholderColor', + term: 'placeholderTerm', + title: 'placeholderDeptCode placeholderCourseNumber', + courseTitle: 'placeholderCourseTitle', + instructors: [], + sectionCode: 'placeholderSectionCode', + sectionType: 'placeholderSectionType', + start: new Date(2018, 0, 5, 1, 2), + end: new Date(2018, 0, 5, 3, 4), + finalExam: { + examStatus: 'SCHEDULED_FINAL', + dayOfWeek: 'Sun', + month: 2, + day: 3, + startTime: { + hour: 1, + minute: 2, + }, + endTime: { + hour: 3, + minute: 4, + }, + bldg: [], + }, + isCustomEvent: false, + }, + ]; + + const calendarizedCourseFinals = [ + { + bldg: undefined, + color: 'placeholderColor', + term: 'placeholderTerm', + title: 'placeholderDeptCode placeholderCourseNumber', + courseTitle: 'placeholderCourseTitle', + instructors: [], + sectionCode: 'placeholderSectionCode', + sectionType: 'Fin', + start: new Date(2018, 0, 0, 1, 2), + end: new Date(2018, 0, 0, 3, 4), + finalExam: { + examStatus: 'SCHEDULED_FINAL', + dayOfWeek: 'Sun', + month: 2, + day: 3, + startTime: { + hour: 1, + minute: 2, + }, + endTime: { + hour: 3, + minute: 4, + }, + bldg: [], + }, + isCustomEvent: false, + }, + ]; + + const customEvents: RepeatingCustomEvent[] = [ + { + title: 'title', + start: '01:02', + end: '03:04', + days: [true, false, true, false, true, false, true], + customEventID: 0, + color: '#000000', + }, + ]; + + const calendarizedCustomEvents = [ + { + isCustomEvent: true, + customEventID: 0, + color: '#000000', + start: new Date(2018, 0, 0, 1, 2), + end: new Date(2018, 0, 0, 3, 4), + title: 'title', + }, + { + isCustomEvent: true, + customEventID: 0, + color: '#000000', + start: new Date(2018, 0, 2, 1, 2), + end: new Date(2018, 0, 2, 3, 4), + title: 'title', + }, + { + isCustomEvent: true, + customEventID: 0, + color: '#000000', + start: new Date(2018, 0, 4, 1, 2), + end: new Date(2018, 0, 4, 3, 4), + title: 'title', + }, + { + isCustomEvent: true, + customEventID: 0, + color: '#000000', + start: new Date(2018, 0, 6, 1, 2), + end: new Date(2018, 0, 6, 3, 4), + title: 'title', + }, + ]; + + test('calendarizeCourseEvents', () => { + const result = calendarizeCourseEvents(courses); + expect(result).toStrictEqual(calendarizedCourses); + }); + + test('calendarizeFinals', () => { + const result = calendarizeFinals(courses); + expect(result).toStrictEqual(calendarizedCourseFinals); + }); + + test('calendarizeCustomEvents', () => { + const result = calendarizeCustomEvents(customEvents); + expect(result).toStrictEqual(calendarizedCustomEvents); + }); +}); diff --git a/apps/antalmanac/tsconfig.json b/apps/antalmanac/tsconfig.json index 5f82b8e52..498a6c3a7 100644 --- a/apps/antalmanac/tsconfig.json +++ b/apps/antalmanac/tsconfig.json @@ -22,7 +22,7 @@ "$lib/*": ["src/lib/*"], "$providers/*": ["src/providers/*"], "$routes/*": ["src/routes/*"], - "$stores/*": ["src/stores/*"], + "$stores/*": ["src/stores/*"] } - }, + } } diff --git a/packages/peterportal-schemas/src/websoc.ts b/packages/peterportal-schemas/src/websoc.ts index d661d2193..3551f7e74 100644 --- a/packages/peterportal-schemas/src/websoc.ts +++ b/packages/peterportal-schemas/src/websoc.ts @@ -1,11 +1,18 @@ -import { type Infer, arrayOf, type } from 'arktype'; +import { type Infer, arrayOf, type, union } from 'arktype'; import { type Quarter, quarters } from 'peterportal-api-next-types'; import enumerate from './enumerate'; +export const HourMinute = type({ + hour: 'number', + minute: 'number', +}); + export const WebsocSectionMeeting = type({ - days: 'string', - time: 'string', + timeIsTBA: 'boolean', bldg: 'string[]', + days: 'string | null', + startTime: union(HourMinute, 'null'), + endTime: union(HourMinute, 'null'), }); export const WebsocSectionEnrollment = type({ @@ -13,6 +20,16 @@ export const WebsocSectionEnrollment = type({ sectionEnrolled: 'string', }); +export const WebSocSectionFinals = type({ + examStatus: '"NO_FINAL" | "TBA_FINAL" | "SCHEDULED_FINAL"', + dayOfWeek: '"Sun" | "Mon" | "Tue" | "Wed" | "Thu" | "Fri" | "Sat" | null', + month: 'number | null', + day: 'number | null', + startTime: union(HourMinute, 'null'), + endTime: union(HourMinute, 'null'), + bldg: 'string[] | null', +}); + export const WebsocSection = type({ sectionCode: 'string', sectionType: 'string', @@ -20,7 +37,7 @@ export const WebsocSection = type({ units: 'string', instructors: 'string[]', meetings: arrayOf(WebsocSectionMeeting), - finalExam: 'string', + finalExam: WebSocSectionFinals, maxCapacity: 'string', numCurrentlyEnrolled: WebsocSectionEnrollment, numOnWaitlist: 'string', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a741100e9..08f4836c2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -238,8 +238,8 @@ importers: specifier: ^13.1.1 version: 13.1.2 peterportal-api-next-types: - specifier: 1.0.0-beta.2 - version: 1.0.0-beta.2 + specifier: 1.0.0-rc.2.68.0 + version: 1.0.0-rc.2.68.0 prettier: specifier: ^2.8.4 version: 2.8.4 @@ -6743,8 +6743,8 @@ packages: resolution: {integrity: sha512-sbQmYiH21t6wIsgFXStJcBZWhMOCjKQspLGdpUEmpYQaR4tL1kwGQ+KNix5EwLJxHM9BnMtK1BJcwu6fOTeqMQ==} dev: false - /peterportal-api-next-types@1.0.0-beta.2: - resolution: {integrity: sha512-y7uFk8nYmOQ9oWxFMCztUoIIDU5eFAfWct92HPGEbIKvTJTDBeNTb57/5qQP0kq0QE0n26dtmLMADvLkbN7P1Q==} + /peterportal-api-next-types@1.0.0-rc.2.68.0: + resolution: {integrity: sha512-gq0k53abt6ea9roA+GlSgP3Rbv+0tC4rGw4gGbrahh+ZNnmTGdlZSF8ISq07DbQ7td8dBev4gMrjrZq+Xn500A==} dev: true /picocolors@1.0.0: From 33065388ca419215208e7d59b1e4ea69079b02fc Mon Sep 17 00:00:00 2001 From: Kevin Wu Date: Sun, 3 Sep 2023 21:14:34 -0700 Subject: [PATCH 32/89] Swap staging 68 to prod endpoint (#683) --- apps/antalmanac/src/lib/api/endpoints.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/antalmanac/src/lib/api/endpoints.ts b/apps/antalmanac/src/lib/api/endpoints.ts index e381fb1ce..3cb2298e0 100644 --- a/apps/antalmanac/src/lib/api/endpoints.ts +++ b/apps/antalmanac/src/lib/api/endpoints.ts @@ -18,6 +18,4 @@ export const REGISTER_NOTIFICATIONS_ENDPOINT = endpointTransform('/api/notificat export const PETERPORTAL_GRAPHQL_ENDPOINT = 'https://api-next.peterportal.org/v1/graphql'; export const PETERPORTAL_REST_ENDPOINT = 'https://api-next.peterportal.org/v1/rest'; -// Testing API -export const PETERPORTAL_REST_ENDPOINT_68 = 'https://staging-68.api-next.peterportal.org/v1/rest'; -export const PETERPORTAL_WEBSOC_ENDPOINT = `${PETERPORTAL_REST_ENDPOINT_68}/websoc`; +export const PETERPORTAL_WEBSOC_ENDPOINT = `${PETERPORTAL_REST_ENDPOINT}/websoc`; From 17033197c886c35737120f58118a881817f496ec Mon Sep 17 00:00:00 2001 From: Kevin Wu Date: Sun, 3 Sep 2023 21:57:16 -0700 Subject: [PATCH 33/89] Overhaul Schedule Select | Edit | Delete (#659) Co-authored-by: Aponia --- apps/antalmanac/package.json | 1 + .../antalmanac/src/actions/AppStoreActions.ts | 4 +- .../components/Calendar/CalendarToolbar.tsx | 273 ++++++++++++++++-- .../EditSchedule/DeleteScheduleDialog.tsx | 28 +- .../Toolbar/EditSchedule/EditSchedule.tsx | 68 ----- .../EditSchedule/ScheduleNameDialog.tsx | 24 +- .../src/components/dialogs/AddSchedule.tsx | 102 +++++++ .../src/components/dialogs/DeleteSchedule.tsx | 69 +++++ .../src/components/dialogs/RenameSchedule.tsx | 107 +++++++ apps/antalmanac/src/stores/AppStore.ts | 4 +- apps/antalmanac/src/stores/Schedules.ts | 15 +- pnpm-lock.yaml | 218 ++++++++++---- 12 files changed, 740 insertions(+), 173 deletions(-) delete mode 100644 apps/antalmanac/src/components/Calendar/Toolbar/EditSchedule/EditSchedule.tsx create mode 100644 apps/antalmanac/src/components/dialogs/AddSchedule.tsx create mode 100644 apps/antalmanac/src/components/dialogs/DeleteSchedule.tsx create mode 100644 apps/antalmanac/src/components/dialogs/RenameSchedule.tsx diff --git a/apps/antalmanac/package.json b/apps/antalmanac/package.json index 39fa48f3d..a5d573144 100644 --- a/apps/antalmanac/package.json +++ b/apps/antalmanac/package.json @@ -28,6 +28,7 @@ "@material-ui/icons": "^4.11.3", "@material-ui/lab": "^4.0.0-alpha.61", "@material-ui/pickers": "^3.3.10", + "@mui/base": "5.0.0-beta.13", "@mui/icons-material": "^5.11.0", "@mui/lab": "^5.0.0-alpha.118", "@mui/material": "^5.11.7", diff --git a/apps/antalmanac/src/actions/AppStoreActions.ts b/apps/antalmanac/src/actions/AppStoreActions.ts index 7bd645068..d829fad79 100644 --- a/apps/antalmanac/src/actions/AppStoreActions.ts +++ b/apps/antalmanac/src/actions/AppStoreActions.ts @@ -202,8 +202,8 @@ export const renameSchedule = (scheduleName: string, scheduleIndex: number) => { AppStore.renameSchedule(scheduleName, scheduleIndex); }; -export const deleteSchedule = () => { - AppStore.deleteSchedule(); +export const deleteSchedule = (scheduleIndex: number) => { + AppStore.deleteSchedule(scheduleIndex); }; export const updateScheduleNote = (newScheduleNote: string, scheduleIndex: number) => { diff --git a/apps/antalmanac/src/components/Calendar/CalendarToolbar.tsx b/apps/antalmanac/src/components/Calendar/CalendarToolbar.tsx index 69311c10f..b99be4bd3 100644 --- a/apps/antalmanac/src/components/Calendar/CalendarToolbar.tsx +++ b/apps/antalmanac/src/components/Calendar/CalendarToolbar.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback } from 'react'; +import { useState, useMemo, useCallback, useEffect } from 'react'; import { Box, Button, @@ -6,28 +6,47 @@ import { Menu, MenuItem, Paper, - Select, + Popover, Tooltip, + Typography, useMediaQuery, - type SelectProps, + useTheme, } from '@mui/material'; -import { Delete, MoreHoriz, Undo } from '@mui/icons-material'; +import { + Add as AddIcon, + ArrowDropDown as ArrowDropDownIcon, + Delete as DeleteIcon, + Edit as EditIcon, + MoreHoriz as MoreHorizIcon, + Undo as UndoIcon, +} from '@mui/icons-material'; import CustomEventDialog from './Toolbar/CustomEventDialog/CustomEventDialog'; -import EditSchedule from './Toolbar/EditSchedule/EditSchedule'; -import ScheduleNameDialog from './Toolbar/EditSchedule/ScheduleNameDialog'; import ExportCalendar from './Toolbar/ExportCalendar'; import ScreenshotButton from './Toolbar/ScreenshotButton'; -import analyticsEnum, { logAnalytics } from '$lib/analytics'; import { changeCurrentSchedule, clearSchedules, undoDelete } from '$actions/AppStoreActions'; +import AddScheduleDialog from '$components/dialogs/AddSchedule'; +import RenameScheduleDialog from '$components/dialogs/RenameSchedule'; +import DeleteScheduleDialog from '$components/dialogs/DeleteSchedule'; +import analyticsEnum, { logAnalytics } from '$lib/analytics'; +import AppStore from '$stores/AppStore'; -const handleScheduleChange: SelectProps['onChange'] = (event) => { +function handleScheduleChange(index: number) { logAnalytics({ category: analyticsEnum.calendar.title, action: analyticsEnum.calendar.actions.CHANGE_SCHEDULE, }); - changeCurrentSchedule(Number(event.target.value)); -}; + changeCurrentSchedule(index); +} + +/** + * Creates an event handler callback that will change the current schedule to the one at a specified index. + */ +function createScheduleSelector(index: number) { + return () => { + handleScheduleChange(index); + }; +} function handleUndo() { logAnalytics({ @@ -36,7 +55,7 @@ function handleUndo() { }); undoDelete(null); } - + function handleClearSchedule() { if (window.confirm('Are you sure you want to clear this schedule?')) { clearSchedules(); @@ -47,7 +66,189 @@ function handleClearSchedule() { } } -interface CalendarPaneToolbarProps { +function EditScheduleButton(props: { index: number }) { + const [open, setOpen] = useState(false); + + const handleOpen = useCallback(() => { + setOpen(true); + }, []); + + const handleClose = useCallback(() => { + setOpen(false); + }, []); + + return ( + + + + + + + ); +} + +function DeleteScheduleButton(props: { index: number }) { + const [open, setOpen] = useState(false); + + const handleOpen = useCallback(() => { + setOpen(true); + }, []); + + const handleClose = useCallback(() => { + setOpen(false); + }, []); + + return ( + + + + + + + ); +} + +/** + * MenuItem nested in the select menu to add a new schedule through a dialog. + */ +function AddScheduleButton() { + const [open, setOpen] = useState(false); + + const handleOpen = useCallback(() => { + setOpen(true); + }, []); + + const handleClose = useCallback(() => { + setOpen(false); + }, []); + + return ( + <> + + + + ); +} + +/** + * Simulates an HTML select element using a popover. + * + * Can select a schedule, and also control schedule settings with buttons. + */ +function SelectSchedulePopover(props: { scheduleNames: string[] }) { + const [currentScheduleIndex, setCurrentScheduleIndex] = useState(AppStore.getCurrentScheduleIndex()); + + const [anchorEl, setAnchorEl] = useState(); + + const theme = useTheme(); + + // TODO: maybe these widths should be dynamic based on i.e. the viewport width? + + const minWidth = useMemo(() => 100, []); + const maxWidth = useMemo(() => 150, []); + + const open = useMemo(() => Boolean(anchorEl), [anchorEl]); + + const currentScheduleName = useMemo(() => { + return props.scheduleNames[currentScheduleIndex]; + }, [props.scheduleNames, currentScheduleIndex]); + + const handleClick = useCallback((event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }, []); + + const handleClose = useCallback(() => { + setAnchorEl(undefined); + }, []); + + const handleScheduleIndexChange = useCallback(() => { + setCurrentScheduleIndex(AppStore.getCurrentScheduleIndex()); + }, []); + + useEffect(() => { + AppStore.on('addedCoursesChange', handleScheduleIndexChange); + AppStore.on('customEventsChange', handleScheduleIndexChange); + AppStore.on('colorChange', handleScheduleIndexChange); + AppStore.on('currentScheduleIndexChange', handleScheduleIndexChange); + + return () => { + AppStore.off('addedCoursesChange', handleScheduleIndexChange); + AppStore.off('customEventsChange', handleScheduleIndexChange); + AppStore.off('colorChange', handleScheduleIndexChange); + AppStore.off('currentScheduleIndexChange', handleScheduleIndexChange); + }; + }, [handleScheduleIndexChange]); + + return ( + + + + + + {props.scheduleNames.map((name, index) => ( + + + + + + + + + + ))} + + + + + + + + ); +} + +export interface CalendarPaneToolbarProps { scheduleNames: string[]; currentScheduleIndex: number; showFinalsSchedule: boolean; @@ -62,9 +263,13 @@ interface CalendarPaneToolbarProps { onTakeScreenshot: (html2CanvasScreenshot: () => void) => void; } -const CalendarPaneToolbar = (props: CalendarPaneToolbarProps) => { - const { scheduleNames, currentScheduleIndex, showFinalsSchedule, toggleDisplayFinalsSchedule, onTakeScreenshot } = - props; +/** + * The root toolbar will pass down the schedule names to its children. + */ +function CalendarPaneToolbar(props: CalendarPaneToolbarProps) { + const { showFinalsSchedule, toggleDisplayFinalsSchedule, onTakeScreenshot } = props; + + const [scheduleNames, setScheduleNames] = useState(AppStore.getScheduleNames()); const [anchorEl, setAnchorEl] = useState(); @@ -86,6 +291,18 @@ const CalendarPaneToolbar = (props: CalendarPaneToolbarProps) => { toggleDisplayFinalsSchedule(); }, [toggleDisplayFinalsSchedule]); + const handleScheduleNamesChange = useCallback(() => { + setScheduleNames(AppStore.getScheduleNames()); + }, []); + + useEffect(() => { + AppStore.on('scheduleNamesChange', handleScheduleNamesChange); + + return () => { + AppStore.off('scheduleNamesChange', handleScheduleNamesChange); + }; + }, [handleScheduleNamesChange]); + return ( { sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, alignItems: 'center', padding: 1 }} > - - - - + @@ -122,21 +329,23 @@ const CalendarPaneToolbar = (props: CalendarPaneToolbarProps) => { - + - + + {/* On mobile devices, render the extra buttons in a menu. */} + {isMobileScreen ? ( - + @@ -160,6 +369,6 @@ const CalendarPaneToolbar = (props: CalendarPaneToolbarProps) => { ); -}; +} export default CalendarPaneToolbar; diff --git a/apps/antalmanac/src/components/Calendar/Toolbar/EditSchedule/DeleteScheduleDialog.tsx b/apps/antalmanac/src/components/Calendar/Toolbar/EditSchedule/DeleteScheduleDialog.tsx index 416234a35..7f919943c 100644 --- a/apps/antalmanac/src/components/Calendar/Toolbar/EditSchedule/DeleteScheduleDialog.tsx +++ b/apps/antalmanac/src/components/Calendar/Toolbar/EditSchedule/DeleteScheduleDialog.tsx @@ -6,18 +6,25 @@ import { DialogContentText, DialogTitle, MenuItem, + IconButton, + Box, + Tooltip, } from '@material-ui/core'; import { useState } from 'react'; +import { Clear } from '@material-ui/icons'; import { deleteSchedule } from '$actions/AppStoreActions'; import { isDarkMode } from '$lib/helpers'; import AppStore from '$stores/AppStore'; interface DeleteScheduleDialogProps { - onClose: () => void; + onClose?: () => void; + scheduleIndex: number; } const DeleteScheduleDialog = (props: DeleteScheduleDialogProps) => { + const { scheduleIndex } = props; + const [isOpen, setIsOpen] = useState(false); const handleOpen = () => { @@ -29,24 +36,25 @@ const DeleteScheduleDialog = (props: DeleteScheduleDialogProps) => { }; const handleDelete = () => { - props.onClose(); - deleteSchedule(); + props.onClose?.(); + deleteSchedule(scheduleIndex); setIsOpen(false); }; return ( - <> + - Delete Schedule + + + + + Delete Schedule - Are you sure you want to delete {`"${AppStore.schedule.getCurrentScheduleName()}"`}? -
-
- You cannot undo this action. + Are you sure you want to delete {`"${AppStore.schedule.getScheduleName(scheduleIndex)}"`}?
@@ -58,7 +66,7 @@ const DeleteScheduleDialog = (props: DeleteScheduleDialogProps) => {
- +
); }; diff --git a/apps/antalmanac/src/components/Calendar/Toolbar/EditSchedule/EditSchedule.tsx b/apps/antalmanac/src/components/Calendar/Toolbar/EditSchedule/EditSchedule.tsx deleted file mode 100644 index bd2ea6aab..000000000 --- a/apps/antalmanac/src/components/Calendar/Toolbar/EditSchedule/EditSchedule.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { Button, Menu, Tooltip } from '@material-ui/core'; -import { withStyles } from '@material-ui/core/styles'; -import { ClassNameMap } from '@material-ui/core/styles/withStyles'; -import { Edit } from '@material-ui/icons'; -import React, { useState } from 'react'; - -import DeleteScheduleDialog from './DeleteScheduleDialog'; -import ScheduleNameDialog from './ScheduleNameDialog'; - -const styles = () => ({ - editButton: { - padding: '3px 7px', - minWidth: 0, - minHeight: 0, - }, -}); - -interface EditScheduleProps { - classes: ClassNameMap; - scheduleNames: string[]; - scheduleIndex: number; -} - -const EditSchedule = (props: EditScheduleProps) => { - const { classes, scheduleNames, scheduleIndex } = props; - const [anchorEl, setAnchorEl] = useState(null); - - const handleClick: React.MouseEventHandler = (event) => { - setAnchorEl(event.currentTarget); - }; - - const handleClose = () => { - setAnchorEl(null); - }; - - return ( - <> - - - - - - - - - ); -}; - -export default withStyles(styles)(EditSchedule); diff --git a/apps/antalmanac/src/components/Calendar/Toolbar/EditSchedule/ScheduleNameDialog.tsx b/apps/antalmanac/src/components/Calendar/Toolbar/EditSchedule/ScheduleNameDialog.tsx index 2dad5b088..33e4bc69c 100644 --- a/apps/antalmanac/src/components/Calendar/Toolbar/EditSchedule/ScheduleNameDialog.tsx +++ b/apps/antalmanac/src/components/Calendar/Toolbar/EditSchedule/ScheduleNameDialog.tsx @@ -1,8 +1,18 @@ import { forwardRef, useCallback, useState, useMemo } from 'react'; -import { Button, Dialog, DialogActions, DialogContent, DialogTitle, MenuItem, TextField } from '@material-ui/core'; +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + IconButton, + MenuItem, + TextField, + Tooltip, +} from '@material-ui/core'; import { withStyles } from '@material-ui/core/styles'; import { ClassNameMap } from '@material-ui/core/styles/withStyles'; -import { Add } from '@material-ui/icons'; +import { Add, Edit } from '@material-ui/icons'; import { addSchedule, renameSchedule } from '$actions/AppStoreActions'; import { isDarkMode } from '$lib/helpers'; @@ -37,7 +47,7 @@ const ScheduleNameDialog = forwardRef((props: ScheduleNameDialogProps, ref) => { // We need to stop propagation so that the select menu won't close const handleOpen = useCallback( - (event: React.MouseEvent) => { + (event: React.MouseEvent) => { event.stopPropagation(); setIsOpen(true); onOpen?.(); @@ -95,7 +105,13 @@ const ScheduleNameDialog = forwardRef((props: ScheduleNameDialogProps, ref) => { return ( <> {rename ? ( - Rename Schedule + + + + + + + ) : ( diff --git a/apps/antalmanac/src/components/dialogs/AddSchedule.tsx b/apps/antalmanac/src/components/dialogs/AddSchedule.tsx new file mode 100644 index 000000000..318da32fe --- /dev/null +++ b/apps/antalmanac/src/components/dialogs/AddSchedule.tsx @@ -0,0 +1,102 @@ +import { useCallback, useState, useEffect, useMemo } from 'react'; +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + TextField, + type DialogProps, + Box, +} from '@mui/material'; +import { addSchedule } from '$actions/AppStoreActions'; +import { isDarkMode } from '$lib/helpers'; +import AppStore from '$stores/AppStore'; + +type ScheduleNameDialogProps = DialogProps; + +/** + * Dialog with a text field to add a schedule. + */ +function AddScheduleDialog(props: ScheduleNameDialogProps) { + /** + * {@link props.onClose} also needs to be forwarded to the {@link Dialog} component. + * A custom {@link onKeyDown} handler is provided to handle the Enter and Escape keys. + */ + const { onKeyDown, ...dialogProps } = props; + + /** + * This is destructured separately for memoization. + */ + const { onClose } = props; + + const [scheduleNames, setScheduleNames] = useState(AppStore.getScheduleNames()); + + const [name, setName] = useState(`Schedule ${scheduleNames.length + 1}`); + + const handleCancel = useCallback(() => { + onClose?.({}, 'escapeKeyDown'); + }, [onClose]); + + const handleNameChange = useCallback((event: React.ChangeEvent) => { + setName(event.target.value); + }, []); + + const submitName = useCallback(() => { + addSchedule(name); + setName(`Schedule ${AppStore.getScheduleNames().length + 1}`); + onClose?.({}, 'escapeKeyDown'); + }, [onClose, name]); + + const handleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + onKeyDown?.(event); + + if (event.key === 'Enter') { + event.stopPropagation(); + event.preventDefault(); + submitName(); + } + + if (event.key === 'Escape') { + props.onClose?.({}, 'escapeKeyDown'); + } + }, + [onClose, submitName, onKeyDown] + ); + + const handleScheduleNamesChange = useCallback(() => { + setScheduleNames(AppStore.getScheduleNames()); + }, []); + + useEffect(() => { + AppStore.on('scheduleNamesChange', handleScheduleNamesChange); + + return () => { + AppStore.off('scheduleNamesChange', handleScheduleNamesChange); + }; + }, [handleScheduleNamesChange]); + + return ( + + Add Schedule + + + + + + + + + + + + + ); +} + +export default AddScheduleDialog; diff --git a/apps/antalmanac/src/components/dialogs/DeleteSchedule.tsx b/apps/antalmanac/src/components/dialogs/DeleteSchedule.tsx new file mode 100644 index 000000000..1dc62fd1d --- /dev/null +++ b/apps/antalmanac/src/components/dialogs/DeleteSchedule.tsx @@ -0,0 +1,69 @@ +import { useCallback, useMemo } from 'react'; +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + type DialogProps, +} from '@mui/material'; +import { deleteSchedule } from '$actions/AppStoreActions'; +import { isDarkMode } from '$lib/helpers'; +import AppStore from '$stores/AppStore'; + +interface ScheduleNameDialogProps extends DialogProps { + /** + * The index of the schedule to rename (i.e. in the schedules array). + */ + index: number; +} + +/** + * Dialog with a prompt to delete the specified schedule. + */ +function DeleteScheduleDialog(props: ScheduleNameDialogProps) { + /** + * {@link props.onClose} also needs to be forwarded to the {@link Dialog} component. + */ + const { index, ...dialogProps } = props; + + /** + * This is destructured separately for memoization. + */ + const { onClose } = props; + + const scheduleName = useMemo(() => { + return AppStore.schedule.getScheduleName(index); + }, [index]); + + const handleCancel = useCallback(() => { + onClose?.({}, 'escapeKeyDown'); + }, [onClose, index]); + + const handleDelete = useCallback(() => { + deleteSchedule(index); + onClose?.({}, 'escapeKeyDown'); + }, [index]); + + return ( + + Delete Schedule + + + Are you sure you want to delete "{scheduleName}"? + + + + + + + + ); +} + +export default DeleteScheduleDialog; diff --git a/apps/antalmanac/src/components/dialogs/RenameSchedule.tsx b/apps/antalmanac/src/components/dialogs/RenameSchedule.tsx new file mode 100644 index 000000000..50f761833 --- /dev/null +++ b/apps/antalmanac/src/components/dialogs/RenameSchedule.tsx @@ -0,0 +1,107 @@ +import { useCallback, useState, useEffect } from 'react'; +import { + Box, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + TextField, + type DialogProps, +} from '@mui/material'; +import { renameSchedule } from '$actions/AppStoreActions'; +import { isDarkMode } from '$lib/helpers'; +import AppStore from '$stores/AppStore'; + +interface ScheduleNameDialogProps extends DialogProps { + /** + * The index of the schedule to rename (i.e. in the schedules array). + */ + index: number; +} + +/** + * Dialog with a form to rename a schedule. + */ +function RenameScheduleDialog(props: ScheduleNameDialogProps) { + /** + * {@link props.onClose} also needs to be forwarded to the {@link Dialog} component. + * A custom {@link onKeyDown} handler is provided to handle the Enter and Escape keys. + */ + const { index, onKeyDown, ...dialogProps } = props; + + /** + * This is destructured separately for memoization. + */ + const { onClose } = props; + + const [scheduleNames, setScheduleNames] = useState(AppStore.getScheduleNames()); + + const [name, setName] = useState(scheduleNames[index]); + + const handleCancel = useCallback(() => { + onClose?.({}, 'escapeKeyDown'); + setName(scheduleNames[index]); + }, [onClose, scheduleNames, index]); + + const handleNameChange = useCallback((event: React.ChangeEvent) => { + setName(event.target.value); + }, []); + + const submitName = useCallback(() => { + renameSchedule(name, index); + onClose?.({}, 'escapeKeyDown'); + }, [onClose, name, index]); + + const handleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + onKeyDown?.(event); + + if (event.key === 'Enter') { + event.preventDefault(); + event.stopPropagation(); + submitName(); + } + + if (event.key === 'Escape') { + onClose?.({}, 'escapeKeyDown'); + } + }, + [onClose, submitName, onKeyDown] + ); + + const handleScheduleNamesChange = useCallback(() => { + setScheduleNames(AppStore.getScheduleNames()); + }, []); + + useEffect(() => { + AppStore.on('scheduleNamesChange', handleScheduleNamesChange); + + return () => { + AppStore.off('scheduleNamesChange', handleScheduleNamesChange); + }; + }, [handleScheduleNamesChange]); + + return ( + + Rename Schedule + + + + + + + + + + + + + ); +} + +export default RenameScheduleDialog; diff --git a/apps/antalmanac/src/stores/AppStore.ts b/apps/antalmanac/src/stores/AppStore.ts index a7e697a05..6637afcc6 100644 --- a/apps/antalmanac/src/stores/AppStore.ts +++ b/apps/antalmanac/src/stores/AppStore.ts @@ -238,8 +238,8 @@ class AppStore extends EventEmitter { this.emit('customEventsChange'); } - deleteSchedule() { - this.schedule.deleteCurrentSchedule(); + deleteSchedule(scheduleIndex: number) { + this.schedule.deleteSchedule(scheduleIndex); this.emit('scheduleNamesChange'); this.emit('currentScheduleIndexChange'); this.emit('addedCoursesChange'); diff --git a/apps/antalmanac/src/stores/Schedules.ts b/apps/antalmanac/src/stores/Schedules.ts index 8537dd75f..6a057b2cc 100644 --- a/apps/antalmanac/src/stores/Schedules.ts +++ b/apps/antalmanac/src/stores/Schedules.ts @@ -44,6 +44,13 @@ export class Schedules { return this.schedules[this.currentScheduleIndex].scheduleName; } + /** + * @return a specific schedule name + */ + getScheduleName(scheduleIndex: number) { + return this.schedules[scheduleIndex].scheduleName; + } + /** * @return a list of all schedule names */ @@ -94,12 +101,12 @@ export class Schedules { } /** - * Deletes current schedule and adjusts schedule index to current + * Deletes specific schedule and adjusts schedule index to current */ - deleteCurrentSchedule() { + deleteSchedule(scheduleIndex: number) { this.addUndoState(); - this.schedules.splice(this.currentScheduleIndex, 1); - this.currentScheduleIndex = Math.min(this.currentScheduleIndex, this.getNumberOfSchedules() - 1); + this.schedules.splice(scheduleIndex, 1); + this.currentScheduleIndex = Math.min(scheduleIndex, this.getNumberOfSchedules() - 1); } /** diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 08f4836c2..a35b33414 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,7 +22,7 @@ importers: version: 2.8.4 turbo: specifier: latest - version: 1.10.12 + version: 1.10.13 apps/antalmanac: dependencies: @@ -47,6 +47,9 @@ importers: '@material-ui/pickers': specifier: ^3.3.10 version: 3.3.10(@date-io/core@1.3.13)(@material-ui/core@4.12.4)(prop-types@15.8.1)(react-dom@18.2.0)(react@18.2.0) + '@mui/base': + specifier: 5.0.0-beta.13 + version: 5.0.0-beta.13(@types/react@18.0.28)(react-dom@18.2.0)(react@18.2.0) '@mui/icons-material': specifier: ^5.11.0 version: 5.11.9(@mui/material@5.11.10)(@types/react@18.0.28)(react@18.2.0) @@ -376,10 +379,10 @@ importers: version: 10.17.27 '@typescript-eslint/eslint-plugin': specifier: ^5.54.1 - version: 5.57.1(@typescript-eslint/parser@5.57.1)(eslint@8.38.0)(typescript@5.1.6) + version: 5.57.1(@typescript-eslint/parser@5.57.1)(eslint@8.38.0)(typescript@5.2.2) '@typescript-eslint/parser': specifier: ^5.54.1 - version: 5.57.1(eslint@8.38.0)(typescript@5.1.6) + version: 5.57.1(eslint@8.38.0)(typescript@5.2.2) aws-cdk: specifier: ^2.66.1 version: 2.66.1 @@ -397,10 +400,10 @@ importers: version: 2.8.4 ts-node: specifier: ^9.0.0 - version: 9.1.1(typescript@5.1.6) + version: 9.1.1(typescript@5.2.2) typescript: specifier: latest - version: 5.1.6 + version: 5.2.2 packages/peterportal-schemas: dependencies: @@ -1425,6 +1428,13 @@ packages: dependencies: regenerator-runtime: 0.13.11 + /@babel/runtime@7.22.11: + resolution: {integrity: sha512-ee7jVNlWN09+KftVOu9n7S8gQzD/Z6hN/I8VBRXW4P1+Xe7kJGXMwu8vds4aGIMHZnNbdpSWCfZZtinytpcAvA==} + engines: {node: '>=6.9.0'} + dependencies: + regenerator-runtime: 0.14.0 + dev: false + /@babel/template@7.20.7: resolution: {integrity: sha512-8SegXApWe6VoNw0r9JHpSteLKTpTiLZ4rMlGIm9JQ18KiCtyQiAMEazujAHrUS5flrcqYZa75ukev3P6QmUwUw==} engines: {node: '>=6.9.0'} @@ -1528,10 +1538,20 @@ packages: '@emotion/memoize': 0.8.0 dev: false + /@emotion/is-prop-valid@1.2.1: + resolution: {integrity: sha512-61Mf7Ufx4aDxx1xlDeOm8aFFigGHE4z+0sKCa+IHCeZKiyP9RLD0Mmx7m8b9/Cf37f7NAvQOOJAbQQGVr5uERw==} + dependencies: + '@emotion/memoize': 0.8.1 + dev: false + /@emotion/memoize@0.8.0: resolution: {integrity: sha512-G/YwXTkv7Den9mXDO7AhLWkE3q+I92B+VqAE+dYG4NGPaHZGvt3G8Q0p9vmE+sq7rTGphUbAvmQ9YpbfMQGGlA==} dev: false + /@emotion/memoize@0.8.1: + resolution: {integrity: sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA==} + dev: false + /@emotion/react@11.10.6(@types/react@18.0.28)(react@18.2.0): resolution: {integrity: sha512-6HT8jBmcSkfzO7mc+N1L9uwvOnlcGoix8Zn7srt+9ga0MjREo6lRpuVX0kzo6Jp6oTqDhREOFsygN6Ew4fEQbw==} peerDependencies: @@ -2077,6 +2097,34 @@ packages: engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} dev: true + /@floating-ui/core@1.4.1: + resolution: {integrity: sha512-jk3WqquEJRlcyu7997NtR5PibI+y5bi+LS3hPmguVClypenMsCY3CBa3LAQnozRCtCrYWSEtAdiskpamuJRFOQ==} + dependencies: + '@floating-ui/utils': 0.1.1 + dev: false + + /@floating-ui/dom@1.5.1: + resolution: {integrity: sha512-KwvVcPSXg6mQygvA1TjbN/gh///36kKtllIF8SUm0qpFj8+rvYrpvlYdL1JoA71SHpDqgSSdGOSoQ0Mp3uY5aw==} + dependencies: + '@floating-ui/core': 1.4.1 + '@floating-ui/utils': 0.1.1 + dev: false + + /@floating-ui/react-dom@2.0.2(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-5qhlDvjaLmAst/rKb3VdlCinwTF4EYMiVxuuc/HVUjs46W0zgtbMmAZ1UTsDrRTxRmUEzl92mOtWbeeXL26lSQ==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + dependencies: + '@floating-ui/dom': 1.5.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + + /@floating-ui/utils@0.1.1: + resolution: {integrity: sha512-m0G6wlnhm/AX0H12IOWtK8gASEMffnX08RtKkCgTdHb9JpHKGloI7icFfLg9ZmQeavcvR0PKmzxClyuFPSjKWw==} + dev: false + /@humanwhocodes/config-array@0.11.8: resolution: {integrity: sha512-UybHIJzJnR5Qc/MsD9Kr+RpO2h+/P1GhOwdiLPXK5TWk5sgTdu88bTD9UP+CKbPPh5Rni1u0GjAdYQLemG8g+g==} engines: {node: '>=10.10.0'} @@ -2379,6 +2427,31 @@ packages: react-is: 18.2.0 dev: false + /@mui/base@5.0.0-beta.13(@types/react@18.0.28)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-uC0l97pBspfDAp+iz2cJq8YZ8Sd9i73V77+WzUiOAckIVEyCm5dyVDZCCO2/phmzckVEeZCGcytybkjMQuhPQw==} + engines: {node: '>=12.0.0'} + peerDependencies: + '@types/react': ^17.0.0 || ^18.0.0 + react: ^17.0.0 || ^18.0.0 + react-dom: ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@babel/runtime': 7.22.11 + '@emotion/is-prop-valid': 1.2.1 + '@floating-ui/react-dom': 2.0.2(react-dom@18.2.0)(react@18.2.0) + '@mui/types': 7.2.4(@types/react@18.0.28) + '@mui/utils': 5.14.7(react@18.2.0) + '@popperjs/core': 2.11.8 + '@types/react': 18.0.28 + clsx: 2.0.0 + prop-types: 15.8.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + react-is: 18.2.0 + dev: false + /@mui/core-downloads-tracker@5.11.9: resolution: {integrity: sha512-YGEtucQ/Nl91VZkzYaLad47Cdui51n/hW+OQm4210g4N3/nZzBxmGeKfubEalf+ShKH4aYDS86XTO6q/TpZnjQ==} dev: false @@ -2550,6 +2623,17 @@ packages: '@types/react': 18.0.28 dev: false + /@mui/types@7.2.4(@types/react@18.0.28): + resolution: {integrity: sha512-LBcwa8rN84bKF+f5sDyku42w1NTxaPgPyYKODsh01U1fVstTClbUoSA96oyRBnSNyEiAVjKm6Gwx9vjR+xyqHA==} + peerDependencies: + '@types/react': '*' + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 18.0.28 + dev: false + /@mui/utils@5.11.9(react@18.2.0): resolution: {integrity: sha512-eOJaqzcEs4qEwolcvFAmXGpln+uvouvOS9FUX6Wkrte+4I8rZbjODOBDVNlK+V6/ziTfD4iNKC0G+KfOTApbqg==} engines: {node: '>=12.0.0'} @@ -2564,6 +2648,20 @@ packages: react-is: 18.2.0 dev: false + /@mui/utils@5.14.7(react@18.2.0): + resolution: {integrity: sha512-RtheP/aBoPogVdi8vj8Vo2IFnRa4mZVmnD0RGlVZ49yF60rZs+xP4/KbpIrTr83xVs34QmHQ2aQ+IX7I0a0dDw==} + engines: {node: '>=12.0.0'} + peerDependencies: + react: ^17.0.0 || ^18.0.0 + dependencies: + '@babel/runtime': 7.22.11 + '@types/prop-types': 15.7.5 + '@types/react-is': 18.2.1 + prop-types: 15.8.1 + react: 18.2.0 + react-is: 18.2.0 + dev: false + /@nodelib/fs.scandir@2.1.5: resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -2589,6 +2687,10 @@ packages: resolution: {integrity: sha512-50/17A98tWUfQ176raKiOGXuYpLyyVMkxxG6oylzL3BPOlA6ADGdK7EYunSa4I064xerltq9TGXs8HmOk5E+vw==} dev: false + /@popperjs/core@2.11.8: + resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} + dev: false + /@react-leaflet/core@2.1.0(leaflet@1.9.3)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-Qk7Pfu8BSarKGqILj4x7bCSZ1pjuAPZ+qmRwH5S7mDS91VSbVVsJSrW4qA+GPrro8t69gFYVMWb1Zc4yFmPiVg==} peerDependencies: @@ -3016,6 +3118,12 @@ packages: '@types/react': 18.0.28 dev: false + /@types/react-is@18.2.1: + resolution: {integrity: sha512-wyUkmaaSZEzFZivD8F2ftSyAfk6L+DfFliVj/mYdOXbVjRcS87fQJLTnhk6dRZPuJjI+9g6RZJO4PNCngUrmyw==} + dependencies: + '@types/react': 18.0.28 + dev: false + /@types/react-lazyload@3.2.0: resolution: {integrity: sha512-4+r+z8Cf7L/mgxA1vl5uHx5GS/8gY2jqq2p5r5WCm+nUsg9KilwQ+8uaJA3EUlLj57AOzOfGGwwRJ5LOVl8fwA==} dependencies: @@ -3149,7 +3257,7 @@ packages: - supports-color dev: true - /@typescript-eslint/eslint-plugin@5.57.1(@typescript-eslint/parser@5.57.1)(eslint@8.38.0)(typescript@5.1.6): + /@typescript-eslint/eslint-plugin@5.57.1(@typescript-eslint/parser@5.57.1)(eslint@8.38.0)(typescript@5.2.2): resolution: {integrity: sha512-1MeobQkQ9tztuleT3v72XmY0XuKXVXusAhryoLuU5YZ+mXoYKZP9SQ7Flulh1NX4DTjpGTc2b/eMu4u7M7dhnQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: @@ -3161,18 +3269,18 @@ packages: optional: true dependencies: '@eslint-community/regexpp': 4.5.0 - '@typescript-eslint/parser': 5.57.1(eslint@8.38.0)(typescript@5.1.6) + '@typescript-eslint/parser': 5.57.1(eslint@8.38.0)(typescript@5.2.2) '@typescript-eslint/scope-manager': 5.57.1 - '@typescript-eslint/type-utils': 5.57.1(eslint@8.38.0)(typescript@5.1.6) - '@typescript-eslint/utils': 5.57.1(eslint@8.38.0)(typescript@5.1.6) + '@typescript-eslint/type-utils': 5.57.1(eslint@8.38.0)(typescript@5.2.2) + '@typescript-eslint/utils': 5.57.1(eslint@8.38.0)(typescript@5.2.2) debug: 4.3.4 eslint: 8.38.0 grapheme-splitter: 1.0.4 ignore: 5.2.4 natural-compare-lite: 1.4.0 semver: 7.3.8 - tsutils: 3.21.0(typescript@5.1.6) - typescript: 5.1.6 + tsutils: 3.21.0(typescript@5.2.2) + typescript: 5.2.2 transitivePeerDependencies: - supports-color dev: true @@ -3217,7 +3325,7 @@ packages: - supports-color dev: true - /@typescript-eslint/parser@5.57.1(eslint@8.38.0)(typescript@5.1.6): + /@typescript-eslint/parser@5.57.1(eslint@8.38.0)(typescript@5.2.2): resolution: {integrity: sha512-hlA0BLeVSA/wBPKdPGxoVr9Pp6GutGoY380FEhbVi0Ph4WNe8kLvqIRx76RSQt1lynZKfrXKs0/XeEk4zZycuA==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: @@ -3229,10 +3337,10 @@ packages: dependencies: '@typescript-eslint/scope-manager': 5.57.1 '@typescript-eslint/types': 5.57.1 - '@typescript-eslint/typescript-estree': 5.57.1(typescript@5.1.6) + '@typescript-eslint/typescript-estree': 5.57.1(typescript@5.2.2) debug: 4.3.4 eslint: 8.38.0 - typescript: 5.1.6 + typescript: 5.2.2 transitivePeerDependencies: - supports-color dev: true @@ -3285,7 +3393,7 @@ packages: - supports-color dev: true - /@typescript-eslint/type-utils@5.57.1(eslint@8.38.0)(typescript@5.1.6): + /@typescript-eslint/type-utils@5.57.1(eslint@8.38.0)(typescript@5.2.2): resolution: {integrity: sha512-/RIPQyx60Pt6ga86hKXesXkJ2WOS4UemFrmmq/7eOyiYjYv/MUSHPlkhU6k9T9W1ytnTJueqASW+wOmW4KrViw==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: @@ -3295,12 +3403,12 @@ packages: typescript: optional: true dependencies: - '@typescript-eslint/typescript-estree': 5.57.1(typescript@5.1.6) - '@typescript-eslint/utils': 5.57.1(eslint@8.38.0)(typescript@5.1.6) + '@typescript-eslint/typescript-estree': 5.57.1(typescript@5.2.2) + '@typescript-eslint/utils': 5.57.1(eslint@8.38.0)(typescript@5.2.2) debug: 4.3.4 eslint: 8.38.0 - tsutils: 3.21.0(typescript@5.1.6) - typescript: 5.1.6 + tsutils: 3.21.0(typescript@5.2.2) + typescript: 5.2.2 transitivePeerDependencies: - supports-color dev: true @@ -3331,7 +3439,7 @@ packages: - supports-color dev: true - /@typescript-eslint/typescript-estree@5.57.1(typescript@5.1.6): + /@typescript-eslint/typescript-estree@5.57.1(typescript@5.2.2): resolution: {integrity: sha512-A2MZqD8gNT0qHKbk2wRspg7cHbCDCk2tcqt6ScCFLr5Ru8cn+TCfM786DjPhqwseiS+PrYwcXht5ztpEQ6TFTw==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: @@ -3346,8 +3454,8 @@ packages: globby: 11.1.0 is-glob: 4.0.3 semver: 7.3.8 - tsutils: 3.21.0(typescript@5.1.6) - typescript: 5.1.6 + tsutils: 3.21.0(typescript@5.2.2) + typescript: 5.2.2 transitivePeerDependencies: - supports-color dev: true @@ -3392,7 +3500,7 @@ packages: - typescript dev: true - /@typescript-eslint/utils@5.57.1(eslint@8.38.0)(typescript@5.1.6): + /@typescript-eslint/utils@5.57.1(eslint@8.38.0)(typescript@5.2.2): resolution: {integrity: sha512-kN6vzzf9NkEtawECqze6v99LtmDiUJCVpvieTFA1uL7/jDghiJGubGZ5csicYHU1Xoqb3oH/R5cN5df6W41Nfg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: @@ -3403,7 +3511,7 @@ packages: '@types/semver': 7.3.13 '@typescript-eslint/scope-manager': 5.57.1 '@typescript-eslint/types': 5.57.1 - '@typescript-eslint/typescript-estree': 5.57.1(typescript@5.1.6) + '@typescript-eslint/typescript-estree': 5.57.1(typescript@5.2.2) eslint: 8.38.0 eslint-scope: 5.1.1 semver: 7.3.8 @@ -4017,6 +4125,11 @@ packages: engines: {node: '>=6'} dev: false + /clsx@2.0.0: + resolution: {integrity: sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q==} + engines: {node: '>=6'} + dev: false + /color-convert@1.9.3: resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} dependencies: @@ -7194,6 +7307,10 @@ packages: /regenerator-runtime@0.13.11: resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==} + /regenerator-runtime@0.14.0: + resolution: {integrity: sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA==} + dev: false + /regexp.prototype.flags@1.4.3: resolution: {integrity: sha512-fjggEOO3slI6Wvgjwflkc4NFRCTZAu5CnNfBd5qOMYhWdn67nJBBu34/TkD++eeFmd8C9r9jfXJ27+nSiRkSUA==} engines: {node: '>= 0.4'} @@ -7763,7 +7880,7 @@ packages: escape-string-regexp: 1.0.5 dev: true - /ts-node@9.1.1(typescript@5.1.6): + /ts-node@9.1.1(typescript@5.2.2): resolution: {integrity: sha512-hPlt7ZACERQGf03M253ytLY3dHbGNGrAq9qIHWUY9XHYl1z7wYngSr3OQ5xmui8o2AaxsONxIzjafLUiWBo1Fg==} engines: {node: '>=10.0.0'} hasBin: true @@ -7775,7 +7892,7 @@ packages: diff: 4.0.2 make-error: 1.3.6 source-map-support: 0.5.21 - typescript: 5.1.6 + typescript: 5.2.2 yn: 3.1.1 dev: true @@ -7810,14 +7927,14 @@ packages: typescript: 4.9.5 dev: true - /tsutils@3.21.0(typescript@5.1.6): + /tsutils@3.21.0(typescript@5.2.2): resolution: {integrity: sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==} engines: {node: '>= 6'} peerDependencies: typescript: '>=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta' dependencies: tslib: 1.14.1 - typescript: 5.1.6 + typescript: 5.2.2 dev: true /tsx@3.12.7: @@ -7831,65 +7948,64 @@ packages: fsevents: 2.3.2 dev: true - /turbo-darwin-64@1.10.12: - resolution: {integrity: sha512-vmDfGVPl5/aFenAbOj3eOx3ePNcWVUyZwYr7taRl0ZBbmv2TzjRiFotO4vrKCiTVnbqjQqAFQWY2ugbqCI1kOQ==} + /turbo-darwin-64@1.10.13: + resolution: {integrity: sha512-vmngGfa2dlYvX7UFVncsNDMuT4X2KPyPJ2Jj+xvf5nvQnZR/3IeDEGleGVuMi/hRzdinoxwXqgk9flEmAYp0Xw==} cpu: [x64] os: [darwin] requiresBuild: true dev: true optional: true - /turbo-darwin-arm64@1.10.12: - resolution: {integrity: sha512-3JliEESLNX2s7g54SOBqqkqJ7UhcOGkS0ywMr5SNuvF6kWVTbuUq7uBU/sVbGq8RwvK1ONlhPvJne5MUqBCTCQ==} + /turbo-darwin-arm64@1.10.13: + resolution: {integrity: sha512-eMoJC+k7gIS4i2qL6rKmrIQGP6Wr9nN4odzzgHFngLTMimok2cGLK3qbJs5O5F/XAtEeRAmuxeRnzQwTl/iuAw==} cpu: [arm64] os: [darwin] requiresBuild: true dev: true optional: true - /turbo-linux-64@1.10.12: - resolution: {integrity: sha512-siYhgeX0DidIfHSgCR95b8xPee9enKSOjCzx7EjTLmPqPaCiVebRYvbOIYdQWRqiaKh9yfhUtFmtMOMScUf1gg==} + /turbo-linux-64@1.10.13: + resolution: {integrity: sha512-0CyYmnKTs6kcx7+JRH3nPEqCnzWduM0hj8GP/aodhaIkLNSAGAa+RiYZz6C7IXN+xUVh5rrWTnU2f1SkIy7Gdg==} cpu: [x64] os: [linux] requiresBuild: true dev: true optional: true - /turbo-linux-arm64@1.10.12: - resolution: {integrity: sha512-K/ZhvD9l4SslclaMkTiIrnfcACgos79YcAo4kwc8bnMQaKuUeRpM15sxLpZp3xDjDg8EY93vsKyjaOhdFG2UbA==} + /turbo-linux-arm64@1.10.13: + resolution: {integrity: sha512-0iBKviSGQQlh2OjZgBsGjkPXoxvRIxrrLLbLObwJo3sOjIH0loGmVIimGS5E323soMfi/o+sidjk2wU1kFfD7Q==} cpu: [arm64] os: [linux] requiresBuild: true dev: true optional: true - /turbo-windows-64@1.10.12: - resolution: {integrity: sha512-7FSgSwvktWDNOqV65l9AbZwcoueAILeE4L7JvjauNASAjjbuzXGCEq5uN8AQU3U5BOFj4TdXrVmO2dX+lLu8Zg==} + /turbo-windows-64@1.10.13: + resolution: {integrity: sha512-S5XySRfW2AmnTeY1IT+Jdr6Goq7mxWganVFfrmqU+qqq3Om/nr0GkcUX+KTIo9mPrN0D3p5QViBRzulwB5iuUQ==} cpu: [x64] os: [win32] requiresBuild: true dev: true optional: true - /turbo-windows-arm64@1.10.12: - resolution: {integrity: sha512-gCNXF52dwom1HLY9ry/cneBPOKTBHhzpqhMylcyvJP0vp9zeMQQkt6yjYv+6QdnmELC92CtKNp2FsNZo+z0pyw==} + /turbo-windows-arm64@1.10.13: + resolution: {integrity: sha512-nKol6+CyiExJIuoIc3exUQPIBjP9nIq5SkMJgJuxsot2hkgGrafAg/izVDRDrRduQcXj2s8LdtxJHvvnbI8hEQ==} cpu: [arm64] os: [win32] requiresBuild: true dev: true optional: true - /turbo@1.10.12: - resolution: {integrity: sha512-WM3+jTfQWnB9W208pmP4oeehZcC6JQNlydb/ZHMRrhmQa+htGhWLCzd6Q9rLe0MwZLPpSPFV2/bN5egCLyoKjQ==} + /turbo@1.10.13: + resolution: {integrity: sha512-vOF5IPytgQPIsgGtT0n2uGZizR2N3kKuPIn4b5p5DdeLoI0BV7uNiydT7eSzdkPRpdXNnO8UwS658VaI4+YSzQ==} hasBin: true - requiresBuild: true optionalDependencies: - turbo-darwin-64: 1.10.12 - turbo-darwin-arm64: 1.10.12 - turbo-linux-64: 1.10.12 - turbo-linux-arm64: 1.10.12 - turbo-windows-64: 1.10.12 - turbo-windows-arm64: 1.10.12 + turbo-darwin-64: 1.10.13 + turbo-darwin-arm64: 1.10.13 + turbo-linux-64: 1.10.13 + turbo-linux-arm64: 1.10.13 + turbo-windows-64: 1.10.13 + turbo-windows-arm64: 1.10.13 dev: true /type-check@0.4.0: @@ -7936,8 +8052,8 @@ packages: hasBin: true dev: true - /typescript@5.1.6: - resolution: {integrity: sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==} + /typescript@5.2.2: + resolution: {integrity: sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==} engines: {node: '>=14.17'} hasBin: true dev: true From 91b5a86872fec97a25db4bdc3eb48cb4c30cbc46 Mon Sep 17 00:00:00 2001 From: Kevin Wu Date: Mon, 4 Sep 2023 00:17:15 -0700 Subject: [PATCH 34/89] Fix Light Mode Coloring (#684) --- apps/antalmanac/src/components/Calendar/CalendarToolbar.tsx | 6 +++--- .../components/RightPane/AddedCourses/AddedCoursePane.tsx | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/antalmanac/src/components/Calendar/CalendarToolbar.tsx b/apps/antalmanac/src/components/Calendar/CalendarToolbar.tsx index b99be4bd3..bc114b402 100644 --- a/apps/antalmanac/src/components/Calendar/CalendarToolbar.tsx +++ b/apps/antalmanac/src/components/Calendar/CalendarToolbar.tsx @@ -55,7 +55,7 @@ function handleUndo() { }); undoDelete(null); } - + function handleClearSchedule() { if (window.confirm('Are you sure you want to clear this schedule?')) { clearSchedules(); @@ -188,7 +188,7 @@ function SelectSchedulePopover(props: { scheduleNames: string[] }) { @@ -203,7 +203,7 @@ class AddedCoursePane extends PureComponent { if ( window.confirm( From 5e5f346f89a6b7ffea68721843a598a4ae73b412 Mon Sep 17 00:00:00 2001 From: Kevin Wu Date: Mon, 4 Sep 2023 16:57:41 -0700 Subject: [PATCH 35/89] Refactor .ics exports for new time formats (#685) Co-authored-by: Aponia --- .../Calendar/Toolbar/ExportCalendar.tsx | 283 +------------- apps/antalmanac/src/lib/download.ts | 350 ++++++++++++++++++ apps/antalmanac/src/stores/AppStore.ts | 12 +- .../tests/calendarize-helpers.test.ts | 4 +- apps/antalmanac/tests/download-ics.test.ts | 100 +++++ 5 files changed, 460 insertions(+), 289 deletions(-) create mode 100644 apps/antalmanac/src/lib/download.ts create mode 100644 apps/antalmanac/tests/download-ics.test.ts diff --git a/apps/antalmanac/src/components/Calendar/Toolbar/ExportCalendar.tsx b/apps/antalmanac/src/components/Calendar/Toolbar/ExportCalendar.tsx index 1446c167d..512c69259 100644 --- a/apps/antalmanac/src/components/Calendar/Toolbar/ExportCalendar.tsx +++ b/apps/antalmanac/src/components/Calendar/Toolbar/ExportCalendar.tsx @@ -1,288 +1,7 @@ import { Tooltip } from '@material-ui/core'; import Button from '@material-ui/core/Button'; import Today from '@material-ui/icons/Today'; -import { saveAs } from 'file-saver'; -import { createEvents } from 'ics'; - -import { openSnackbar } from '$actions/AppStoreActions'; -import analyticsEnum, { logAnalytics } from '$lib/analytics'; -import { termData } from '$lib/termData'; -import AppStore from '$stores/AppStore'; - -const quarterStartDates = Object.fromEntries( - termData - .filter((term) => term.startDate !== undefined) - .map((term) => [term.shortName, term.startDate as [number, number, number]]) -); -const daysOfWeek = ['Su', 'M', 'Tu', 'W', 'Th', 'F', 'Sa'] as const; -const daysOffset: Record = { SU: -1, MO: 0, TU: 1, WE: 2, TH: 3, FR: 4, SA: 5 }; -const fallDaysOffset: Record = { TH: 0, FR: 1, SA: 2, SU: 3, MO: 4, TU: 5, WE: 6 }; -const translateDaysForIcs = { Su: 'SU', M: 'MO', Tu: 'TU', W: 'WE', Th: 'TH', F: 'FR', Sa: 'SA' }; -const vTimeZoneSection = - 'BEGIN:VTIMEZONE\n' + - 'TZID:America/Los_Angeles\n' + - 'X-LIC-LOCATION:America/Los_Angeles\n' + - 'BEGIN:DAYLIGHT\n' + - 'TZOFFSETFROM:-0800\n' + - 'TZOFFSETTO:-0700\n' + - 'TZNAME:PDT\n' + - 'DTSTART:19700308T020000\n' + - 'RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=2SU\n' + - 'END:DAYLIGHT\n' + - 'BEGIN:STANDARD\n' + - 'TZOFFSETFROM:-0700\n' + - 'TZOFFSETTO:-0800\n' + - 'TZNAME:PST\n' + - 'DTSTART:19701101T020000\n' + - 'RRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=1SU\n' + - 'END:STANDARD\n' + - 'END:VTIMEZONE\n' + - 'BEGIN:VEVENT'; - -/** [YEAR, MONTH, DAY, HOUR, MINUTE]*/ -type DateTimeArray = [number, number, number, number, number]; -/** [YEAR, MONTH, DAY]*/ -type YearMonthDay = [number, number, number]; -/** [HOUR, MINUTE]*/ -type HourMinute = [number, number]; - -/** getByDays returns the days that a class occurs - Given a string of days, convert it to a list of days in ics format - Ex: ("TuThF") -> ["TU", "TH", "FR"] */ -const getByDays = (days: string) => { - return daysOfWeek.filter((day) => days.includes(day)).map((day) => translateDaysForIcs[day]); -}; - -/** getClassStartDate returns the start date of a class - Given the term and bydays, this computes the start date of the class - Ex: ("2021 Spring", 'Tu') -> [2021, 3, 30] */ -const getClassStartDate = (term: string, bydays: ReturnType) => { - // Get the start date of the quarter (Monday) - const quarterStartDate = new Date(...quarterStartDates[term]); - - // dayOffset represents the number of days since the start of the quarter - let dayOffset; - if (getQuarter(term) === 'Fall') { - // Since Fall quarter starts on a Thursday the first byday and offset - // will be different from other quarters - bydays.sort((day1, day2) => { - // Sorts bydays to match this ordering: [TH, FR, SA, SU, MO, TU, WE] - return fallDaysOffset[day1] - fallDaysOffset[day2]; - }); - dayOffset = fallDaysOffset[bydays[0]]; - } else { - dayOffset = daysOffset[bydays[0]]; - } - - // Add the dayOffset to the quarterStartDate - // Date object will handle potential overflow into the next month - quarterStartDate.setDate(quarterStartDate.getDate() + dayOffset); - - // Return [Year, Month, Date] - return dateToIcs(quarterStartDate); -}; - -/** dateToIcs takes a Date object and returns it in ics format [YYYY, MM, DD] */ -const dateToIcs = (date: Date) => { - return [ - date.getFullYear(), - date.getMonth() + 1, // Add 1 month since it is 0-indexed - date.getDate(), - ] as YearMonthDay; -}; - -/** getFirstClass returns the start and end datetime of the first class - Ex: ([2021, 3, 30], " 4:00-4:50p") -> [[2021, 3, 30, 16, 0], [2021, 3, 30, 16, 50]] */ -const getFirstClass = (date: YearMonthDay, time: string): [DateTimeArray, DateTimeArray] => { - const [classStartTime, classEndTime] = parseTimes(time); - return [ - [...date, ...classStartTime], - [...date, ...classEndTime], - ]; -}; - -/** getExamTime returns the start and end datetime of an exam - Ex: ("Mon Jun 7 10:30-12:30pm", "2019") -> [[2019, 6, 7, 10, 30], [2019, 6, 7, 12, 30]] */ -const months: Record = { Mar: 3, Jun: 6, Jul: 7, Aug: 8, Sep: 9, Dec: 12 }; -const getExamTime = (exam: string, year: number) => { - const [, month, day, time] = exam.split(' '); - const [examStartTime, examEndTime] = parseTimes(time); - - return [ - [year, months[month], parseInt(day), ...examStartTime], - [year, months[month], parseInt(day), ...examEndTime], - ]; -}; - -/** parseTimes converts a time string to a - This is a helper function used by getFirstClass - Ex: " 4:00-4:50p" -> [[16, 0], [16, 50]] */ -const parseTimes = (time: string) => { - // Determine whether the time is in the afternoon (PM) - let pm = false; - if (time.slice(-1) === 'p') { - // Course time strings would end with a 'p' - time = time.substring(0, time.length - 1); // Remove 'p' from the end - pm = true; - } else if (time.slice(-2) === 'pm') { - // Final Exam time strings would end with a 'pm' - time = time.substring(0, time.length - 2); // Remove 'pm' from the end - pm = true; - } - - // Get the [start, end] times in [hour, minute] format - const [start, end] = time - .split('-') // Ex: [" 4:00", "4:50"] - .map( - (timeString) => - timeString - .split(':') // Ex: [[" 4", "00"], ["4", "50"]] - .map((val) => parseInt(val)) as HourMinute // Ex: [[4, 0], [4, 50]] - ); - - // Add 12 hours if the time is PM - // However don't add 12 if it is noon - if (pm && end[0] !== 12) { - // Only add 12 to start if start is greater than end - // We don't want to add 12 if the start is in the AM - // E.g. 11:00-12:00 => don't add 12 to start - // E.g. 1:00-2:00 => add 12 to start - if (start[0] <= end[0]) { - start[0] += 12; - } - end[0] += 12; - } - - return [start, end] as const; -}; - -/** getYear returns the year of a given term - Ex: "2019 Fall" -> "2019" */ -const getYear = (term: string) => { - return parseInt(term.split(' ')[0]); -}; - -/** getQuarter returns the quarter of a given term - Ex: "2019 Fall" -> "Fall" */ -const getQuarter = (term: string) => { - return term.split(' ')[1]; -}; - -// getTermLength returns the number of weeks in a given term, -// which is 10 for quarters and Summer Session 10wk, -// and 5 for Summer Sessions I and II -const getTermLength = (quarter: string) => (quarter.startsWith('Summer') && quarter !== 'Summer10wk' ? 5 : 10); - -// getRRule returns a string representing the recurring rule for the VEvent -// Ex: ["TU", "TH"] -> "FREQ=WEEKLY;BYDAY=TU,TH;INTERVAL=1;COUNT=20" -const getRRule = (bydays: ReturnType, quarter: string) => { - let count = getTermLength(quarter) * bydays.length; // Number of occurences in the quarter - switch (quarter) { - case 'Fall': - for (const byday of bydays) { - switch (byday) { - case 'TH': - case 'FR': - case 'SA': - count += 1; // account for Week 0 course meetings - break; - default: - break; - } - } - break; - case 'Summer1': - if (bydays.includes('MO')) count += 1; // instruction ends Monday of Week 6 - break; - case 'Summer10wk': - if (bydays.includes('FR')) count -= 1; // instruction ends Thursday of Week 10 - break; - default: - break; - } - return `FREQ=WEEKLY;BYDAY=${bydays.toString()};INTERVAL=1;COUNT=${count}`; -}; - -const exportCalendar = () => { - // Fetch courses for the current schedule - const courses = AppStore.schedule.getCurrentCourses(); - - // Construct an array of VEvents for each event - const events = []; - for (const course of courses) { - const { - term, - deptCode, - courseNumber, - courseTitle, - section: { sectionType, instructors, meetings, finalExam }, - } = course; - - // Create a VEvent for each meeting - for (const meeting of meetings) { - if (meeting.time === 'TBA') { - // Skip this meeting if there is no meeting time - continue; - } - const bydays = getByDays(meeting.days); - const classStartDate = getClassStartDate(term, bydays); - const [firstClassStart, firstClassEnd] = getFirstClass(classStartDate, meeting.time); - const rrule = getRRule(bydays, getQuarter(term)); - - // Add VEvent to events array - events.push({ - productId: 'antalmanac/ics', - startOutputType: 'local' as const, - endOutputType: 'local' as const, - title: `${deptCode} ${courseNumber} ${sectionType}`, - description: `${courseTitle}\nTaught by ${instructors.join('/')}`, - location: `${meeting.bldg}`, - start: firstClassStart as DateTimeArray, - end: firstClassEnd as DateTimeArray, - recurrenceRule: rrule, - }); - } - - // Add Final to events - if (finalExam && finalExam !== 'TBA') { - const [examStart, examEnd] = getExamTime(finalExam, getYear(term)); - events.push({ - productId: 'antalmanac/ics', - startOutputType: 'local' as const, - endOutputType: 'local' as const, - title: `${deptCode} ${courseNumber} Final Exam`, - description: `Final Exam for ${courseTitle}`, - start: examStart as DateTimeArray, - end: examEnd as DateTimeArray, - }); - } - } - - // Convert the events into a vcalendar - // Callback function triggers a download of the .ics file - createEvents(events, (err, val) => { - logAnalytics({ - category: 'Calendar Pane', - action: analyticsEnum.calendar.actions.DOWNLOAD, - }); - if (!err) { - // Add timezone information to start and end times for events - const icsString = val - .replaceAll('DTSTART', 'DTSTART;TZID=America/Los_Angeles') - .replaceAll('DTEND', 'DTEND;TZID=America/Los_Angeles'); - // Download the .ics file - saveAs( - // inject the VTIMEZONE section into the .ics file - new Blob([icsString.replace('BEGIN:VEVENT', vTimeZoneSection)], { type: 'text/plain;charset=utf-8' }), - 'schedule.ics' - ); - openSnackbar('success', 'Schedule downloaded!', 5); - } else { - openSnackbar('error', 'Something went wrong! Unable to download schedule.', 5); - console.log(err); - } - }); -}; +import { exportCalendar } from '$lib/download'; const ExportCalendarButton = () => ( diff --git a/apps/antalmanac/src/lib/download.ts b/apps/antalmanac/src/lib/download.ts new file mode 100644 index 000000000..e1447895f --- /dev/null +++ b/apps/antalmanac/src/lib/download.ts @@ -0,0 +1,350 @@ +import type { HourMinute, WebsocSectionFinalExam } from 'peterportal-api-next-types'; +import { saveAs } from 'file-saver'; +import { createEvents, type EventAttributes } from 'ics'; +import { notNull } from './utils'; +import { openSnackbar } from '$actions/AppStoreActions'; +import analyticsEnum, { logAnalytics } from '$lib/analytics'; +import { termData } from '$lib/termData'; +import AppStore from '$stores/AppStore'; + +export const quarterStartDates = Object.fromEntries( + termData + .filter((term) => term.startDate !== undefined) + .map((term) => [term.shortName, term.startDate as [number, number, number]]) +); + +export const months: Record = { Mar: 3, Jun: 6, Jul: 7, Aug: 8, Sep: 9, Dec: 12 }; + +export const daysOfWeek = ['Su', 'M', 'Tu', 'W', 'Th', 'F', 'Sa'] as const; + +export const daysOffset: Record = { SU: -1, MO: 0, TU: 1, WE: 2, TH: 3, FR: 4, SA: 5 }; + +export const fallDaysOffset: Record = { TH: 0, FR: 1, SA: 2, SU: 3, MO: 4, TU: 5, WE: 6 }; + +export const translateDaysForIcs = { Su: 'SU', M: 'MO', Tu: 'TU', W: 'WE', Th: 'TH', F: 'FR', Sa: 'SA' }; + +export const vTimeZoneSection = + 'BEGIN:VTIMEZONE\n' + + 'TZID:America/Los_Angeles\n' + + 'X-LIC-LOCATION:America/Los_Angeles\n' + + 'BEGIN:DAYLIGHT\n' + + 'TZOFFSETFROM:-0800\n' + + 'TZOFFSETTO:-0700\n' + + 'TZNAME:PDT\n' + + 'DTSTART:19700308T020000\n' + + 'RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=2SU\n' + + 'END:DAYLIGHT\n' + + 'BEGIN:STANDARD\n' + + 'TZOFFSETFROM:-0700\n' + + 'TZOFFSETTO:-0800\n' + + 'TZNAME:PST\n' + + 'DTSTART:19701101T020000\n' + + 'RRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=1SU\n' + + 'END:STANDARD\n' + + 'END:VTIMEZONE\n' + + 'BEGIN:VEVENT'; + +/** + * @example [YEAR, MONTH, DAY, HOUR, MINUTE] + */ +export type DateTimeArray = [number, number, number, number, number]; + +/** + * @example [YEAR, MONTH, DAY] + */ +export type YearMonthDay = [number, number, number]; + +/** + * Get the days that a class occurs. + * Given a string of days, convert it to a list of days in ics format + * + * @example ("TuThF") -> ["TU", "TH", "FR"] + */ +export function getByDays(days: string): string[] { + return daysOfWeek.filter((day) => days.includes(day)).map((day) => translateDaysForIcs[day]); +} + +/** + * Get the start date of a class + * Given the term and bydays, this computes the start date of the class. + * + * @example ("2021 Spring", 'Tu') -> [2021, 3, 30] + */ +export function getClassStartDate(term: string, bydays: string[]) { + // Get the start date of the quarter (Monday) + const quarterStartDate = new Date(...quarterStartDates[term]); + + // The number of days since the start of the quarter. + let dayOffset: number; + + // Since Fall quarter starts on a Thursday, + // the first byday and offset will be different from other quarters. + // Sort by this ordering: [TH, FR, SA, SU, MO, TU, WE] + if (getQuarter(term) === 'Fall') { + bydays.sort((day1, day2) => { + return fallDaysOffset[day1] - fallDaysOffset[day2]; + }); + dayOffset = fallDaysOffset[bydays[0]]; + } else { + dayOffset = daysOffset[bydays[0]]; + } + + // Add the dayOffset to the quarterStartDate + // Date object will handle potential overflow into the next month + quarterStartDate.setDate(quarterStartDate.getDate() + dayOffset); + + // Return [Year, Month, Date] + return dateToIcs(quarterStartDate); +} + +/** + * Convert a Date object to ics format, i.e. [YYYY, MM, DD] + */ +export function dateToIcs(date: Date) { + return [ + date.getFullYear(), + date.getMonth() + 1, // Add 1 month since it is 0-indexed + date.getDate(), + ] as YearMonthDay; +} + +/** + * Get the start and end datetime of the first class. + * + * @example ([2021, 3, 30], " 4:00-4:50p") -> [[2021, 3, 30, 16, 0], [2021, 3, 30, 16, 50]] + */ +export function getFirstClass( + date: YearMonthDay, + startTime: HourMinute, + endTime: HourMinute +): [DateTimeArray, DateTimeArray] { + const [classStartTime, classEndTime] = parseTimes(startTime, endTime); + return [ + [...date, ...classStartTime], + [...date, ...classEndTime], + ]; +} + +/** + * Get the start and end datetime of an exam + * + * @example + * + * ```ts + * const exam = { + * month: 6, + * day: 7, + * startTime: { + * hour: 10, + * minute: 30, + * }, + * endTime: { + * hour: 12, + * minute: 30, + * }, + * ... + * } + * + * const year = 2019 + * + * const [examStart, examEnd] = getExamTime(exam, year) + * + * // examStart = [2019, 6, 7, 10, 30] + * // examEnd = [2019, 6, 7, 12, 30] + * ``` + */ +export function getExamTime(exam: WebsocSectionFinalExam, year: number): [DateTimeArray, DateTimeArray] | [] { + if (exam.month && exam.day && exam.startTime && exam.endTime) { + const month = exam.month; + const day = exam.day; + const [examStartTime, examEndTime] = parseTimes(exam.startTime, exam.endTime); + + return [ + [year, month + 1, day, ...examStartTime], + [year, month + 1, day, ...examEndTime], + ]; + } else { + // This should never happen, but we return an empty array for typescript purposes + return []; + } +} + +/** + * Helper to convert a time string to an array format. + * + * @example { hour: 16, minute: 0}, { hour: 16, minute: 50 } -> [[16, 0], [16, 50]] + */ +export function parseTimes(startTime: HourMinute, endTime: HourMinute) { + return [ + [startTime.hour, startTime.minute], + [endTime.hour, endTime.minute], + ] as const; +} + +/** + * Get the year of a given term. + * + * @example "2019 Fall" -> "2019" + */ +export function getYear(term: string) { + return parseInt(term.split(' ')[0]); +} + +/** + * Get the quarter of a given term. + * + * @example "2019 Fall" -> "Fall" + */ +export function getQuarter(term: string) { + return term.split(' ')[1]; +} + +/** + * Get the number of weeks in a given term. + * + * @example 10 for quarters and Summer Session 10wk, 5 for Summer Sessions I and II. + */ +export function getTermLength(quarter: string) { + return quarter.startsWith('Summer') && quarter !== 'Summer10wk' ? 5 : 10; +} + +/** + * Get a string representing the recurring rule for the VEvent. + * + * @example ["TU", "TH"] -> "FREQ=WEEKLY;BYDAY=TU,TH;INTERVAL=1;COUNT=20" + */ +export function getRRule(bydays: string[], quarter: string) { + /** + * Number of occurences in the quarter + */ + let count = getTermLength(quarter) * bydays.length; + + switch (quarter) { + case 'Fall': + for (const byday of bydays) { + switch (byday) { + case 'TH': + case 'FR': + case 'SA': + count += 1; // account for Week 0 course meetings + break; + default: + break; + } + } + break; + case 'Summer1': + if (bydays.includes('MO')) count += 1; // instruction ends Monday of Week 6 + break; + case 'Summer10wk': + if (bydays.includes('FR')) count -= 1; // instruction ends Thursday of Week 10 + break; + default: + break; + } + + return `FREQ=WEEKLY;BYDAY=${bydays.toString()};INTERVAL=1;COUNT=${count}`; +} + +export function getEventsFromCourses(courses = AppStore.schedule.getCurrentCourses()): EventAttributes[] { + const events = courses.flatMap((course) => { + const { + term, + deptCode, + courseNumber, + courseTitle, + section: { sectionType, instructors, meetings, finalExam }, + } = course; + + const courseEvents: EventAttributes[] = meetings + .map((meeting) => { + if (meeting.timeIsTBA) { + return; + } + + if (!(meeting.days && meeting.startTime && meeting.endTime)) { + return; + } + + const bydays = getByDays(meeting.days); + + const classStartDate = getClassStartDate(term, bydays); + + const [firstClassStart, firstClassEnd] = getFirstClass( + classStartDate, + meeting.startTime, + meeting.endTime + ); + + const rrule = getRRule(bydays, getQuarter(term)); + + // Add VEvent to events array. + return { + productId: 'antalmanac/ics', + startOutputType: 'local' as const, + endOutputType: 'local' as const, + title: `${deptCode} ${courseNumber} ${sectionType}`, + description: `${courseTitle}\nTaught by ${instructors.join('/')}`, + location: `${meeting.bldg}`, + start: firstClassStart, + end: firstClassEnd, + recurrenceRule: rrule, + }; + }) + .filter(notNull); + + // Add final to events. + if (finalExam.examStatus === 'SCHEDULED_FINAL') { + const [examStart, examEnd] = getExamTime(finalExam, getYear(term)); + + if (examStart && examEnd) { + courseEvents.push({ + productId: 'antalmanac/ics', + startOutputType: 'local' as const, + endOutputType: 'local' as const, + title: `${deptCode} ${courseNumber} Final Exam`, + description: `Final Exam for ${courseTitle}`, + start: examStart, + end: examEnd, + }); + } + } + + return courseEvents; + }); + + return events; +} + +export function exportCalendar() { + const events = getEventsFromCourses(); + + // Convert the events into a vcalendar. + // Callback function triggers a download of the .ics file + createEvents(events, (error, value) => { + logAnalytics({ + category: 'Calendar Pane', + action: analyticsEnum.calendar.actions.DOWNLOAD, + }); + + if (error) { + openSnackbar('error', 'Something went wrong! Unable to download schedule.', 5); + console.log(error); + return; + } + + // Add timezone information to start and end times for events + const icsString = value + .replaceAll('DTSTART', 'DTSTART;TZID=America/Los_Angeles') + .replaceAll('DTEND', 'DTEND;TZID=America/Los_Angeles'); + + // Inject the VTIMEZONE section into the .ics file. + const data = new Blob([icsString.replace('BEGIN:VEVENT', vTimeZoneSection)], { + type: 'text/plain;charset=utf-8', + }); + + // Download the .ics file + saveAs(data, 'schedule.ics'); + openSnackbar('success', 'Schedule downloaded!', 5); + }); +} diff --git a/apps/antalmanac/src/stores/AppStore.ts b/apps/antalmanac/src/stores/AppStore.ts index 6637afcc6..1728a53cd 100644 --- a/apps/antalmanac/src/stores/AppStore.ts +++ b/apps/antalmanac/src/stores/AppStore.ts @@ -41,11 +41,13 @@ class AppStore extends EventEmitter { return theme === null ? 'auto' : theme; })(); - window.addEventListener('beforeunload', (event) => { - if (this.unsavedChanges) { - event.returnValue = `Are you sure you want to leave? You have unsaved changes!`; - } - }); + if (typeof window !== 'undefined') { + window.addEventListener('beforeunload', (event) => { + if (this.unsavedChanges) { + event.returnValue = `Are you sure you want to leave? You have unsaved changes!`; + } + }); + } } getCurrentScheduleIndex() { diff --git a/apps/antalmanac/tests/calendarize-helpers.test.ts b/apps/antalmanac/tests/calendarize-helpers.test.ts index 4a2e5d5ff..5b175f9bf 100644 --- a/apps/antalmanac/tests/calendarize-helpers.test.ts +++ b/apps/antalmanac/tests/calendarize-helpers.test.ts @@ -1,10 +1,10 @@ import { describe, test, expect } from 'vitest'; -import type { ScheduleCourse } from '@packages/antalmanac-types'; +import type { Schedule } from '@packages/antalmanac-types'; import type { RepeatingCustomEvent } from '$components/Calendar/Toolbar/CustomEventDialog/CustomEventDialog'; import { calendarizeCourseEvents, calendarizeCustomEvents, calendarizeFinals } from '$stores/calendarizeHelpers'; describe('calendarize-helpers', () => { - const courses: ScheduleCourse[] = [ + const courses: Schedule['courses'] = [ { courseComment: 'placeholderCourseComment', courseNumber: 'placeholderCourseNumber', diff --git a/apps/antalmanac/tests/download-ics.test.ts b/apps/antalmanac/tests/download-ics.test.ts new file mode 100644 index 000000000..f4d62aa41 --- /dev/null +++ b/apps/antalmanac/tests/download-ics.test.ts @@ -0,0 +1,100 @@ +import { EventAttributes } from 'ics'; +import type { Schedule } from '@packages/antalmanac-types'; +import { describe, test, expect } from 'vitest'; +import { getEventsFromCourses } from '$lib/download'; + +describe('download-ics', () => { + test('converts schedule courses to events for the ics library', () => { + const courses: Schedule['courses'] = [ + { + courseComment: 'placeholderCourseComment', + courseNumber: 'placeholderCourseNumber', + courseTitle: 'placeholderCourseTitle', + deptCode: 'placeholderDeptCode', + prerequisiteLink: 'placeholderPrerequisiteLink', + section: { + color: 'placeholderColor', + sectionCode: 'placeholderSectionCode', + sectionType: 'placeholderSectionType', + sectionNum: 'placeholderSectionNum', + units: 'placeholderUnits', + instructors: ['placeholderInstructor1', 'placeholderInstructor2'], + meetings: [ + { + timeIsTBA: false, + bldg: ['placeholderLocation'], + days: 'MWF', + startTime: { + hour: 1, + minute: 2, + }, + endTime: { + hour: 3, + minute: 4, + }, + }, + ], + finalExam: { + examStatus: 'SCHEDULED_FINAL', + dayOfWeek: 'Sun', + month: 2, + day: 3, + startTime: { + hour: 1, + minute: 2, + }, + endTime: { + hour: 3, + minute: 4, + }, + bldg: [], + }, + maxCapacity: 'placeholderMaxCapacity', + numCurrentlyEnrolled: { + totalEnrolled: 'placeholderTotalEnrolled', + sectionEnrolled: 'placeholderSectionEnrolled', + }, + numOnWaitlist: 'placeholderNumOnWaitlist', + numWaitlistCap: 'placeholderNumWaitlistCap', + numRequested: 'placeholderNumRequested', + numNewOnlyReserved: 'placeholderNumNewOnlyReserved', + restrictions: 'placeholderRestrictions', + status: 'OPEN', + sectionComment: 'placeholderSectionComment', + }, + term: '2023 Fall', // Cannot be a random placeholder; it has to be in `quarterStartDates` otherwise it'll be undefined + }, + ]; + + const expectedResult: EventAttributes[] = [ + { + productId: 'antalmanac/ics', + startOutputType: 'local', + endOutputType: 'local', + title: 'placeholderDeptCode placeholderCourseNumber placeholderSectionType', + description: 'placeholderCourseTitle\nTaught by placeholderInstructor1/placeholderInstructor2', + location: 'placeholderLocation', + start: [2023, 9, 29, 1, 2], + end: [2023, 9, 29, 3, 4], + recurrenceRule: 'FREQ=WEEKLY;BYDAY=FR,MO,WE;INTERVAL=1;COUNT=31', + }, + { + productId: 'antalmanac/ics', + startOutputType: 'local', + endOutputType: 'local', + title: 'placeholderDeptCode placeholderCourseNumber Final Exam', + description: 'Final Exam for placeholderCourseTitle', + start: [2023, 3, 3, 1, 2], + end: [2023, 3, 3, 3, 4], + }, + ]; + + const result = getEventsFromCourses(courses); + + expect(result).toEqual(expectedResult); + }); + + test('ics file has the correct contents', () => { + /* TODO */ + }); +}); From 52162b594334a47036086d3f6bdd6ec1656b8d0c Mon Sep 17 00:00:00 2001 From: Eric Pedley Date: Tue, 5 Sep 2023 17:19:34 -0400 Subject: [PATCH 36/89] Add Vitest to GitHub Actions (#682) Co-authored-by: Aponia --- .github/workflows/test.yaml | 66 ++++++++++++++++++++++++++++++++++++ README.md | 2 +- apps/antalmanac/package.json | 2 +- apps/backend/package.json | 2 +- package.json | 6 ++-- pnpm-lock.yaml | 21 +++++++----- vitest.workspace.ts | 8 +++++ 7 files changed, 93 insertions(+), 14 deletions(-) create mode 100644 .github/workflows/test.yaml create mode 100644 vitest.workspace.ts diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 000000000..14b37e2d9 --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,66 @@ +name: Test + +on: + pull_request: + types: + - opened + - synchronize + - unlabeled + +permissions: + id-token: write + contents: read + deployments: write + pull-requests: write + +concurrency: + group: ${{ github.head_ref }} + cancel-in-progress: true + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Install pnpm + uses: pnpm/action-setup@v2 + with: + version: 8.6.0 + + - name: Setup Node.js with pnpm cache + uses: actions/setup-node@v3 + with: + node-version: lts/hydrogen + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build + env: + HUSKY: 0 + + # Frontend environment variables. + VITE_ENDPOINT: ${{ env.apiSubDomain }} + NODE_ENV: ${{ env.nodeEnv }} + + # Backend environment variables. + HOSTED_ZONE_ID: ${{ secrets.HOSTED_ZONE_ID }} + CERTIFICATE_ARN: ${{ secrets.CERTIFICATE_ARN }} + MONGODB_URI_PROD: ${{ secrets.MONGODB_URI_PROD }} + PR_NUM: ${{ github.event.pull_request.number }} + + # CDK environment variables. + API_SUB_DOMAIN: ${{ env.apiSubDomain }} + + # Turborepo credentials. + TURBO_API: ${{ vars.TURBO_API }} + TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} + TURBO_TEAM: "team_antalmanac" + run: pnpm build + + - name: Test + run: pnpm test run diff --git a/README.md b/README.md index 50a05c8e9..c7fd2ba30 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ If you ever need help, feel free to ask around on our [Discord server](https://d #### Running Tests 1. go into `apps/antalmanac` -2. `pnpm run test` +2. `pnpm test` ### Running the [Backend](https://github.com/icssc/antalmanac-backend) diff --git a/apps/antalmanac/package.json b/apps/antalmanac/package.json index a5d573144..8002953c4 100644 --- a/apps/antalmanac/package.json +++ b/apps/antalmanac/package.json @@ -36,7 +36,7 @@ "@react-leaflet/core": "^2.1.0", "@tanstack/react-query": "^4.24.4", "@trpc/client": "^10.30.0", - "@trpc/server": "^10.23.0", + "@trpc/server": "^10.30.0", "chart.js": "^4.2.1", "classnames": "^2.3.2", "date-fns": "^2.29.3", diff --git a/apps/backend/package.json b/apps/backend/package.json index 812203439..6f1d36e3e 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -11,7 +11,7 @@ }, "dependencies": { "@packages/antalmanac-types": "*", - "@trpc/server": "^10.23.0", + "@trpc/server": "^10.30.0", "@vendia/serverless-express": "^4.10.1", "arktype": "1.0.14-alpha", "aws-lambda": "^1.0.7", diff --git a/package.json b/package.json index 07d049a73..bb7e07ec6 100644 --- a/package.json +++ b/package.json @@ -10,14 +10,16 @@ "build": "turbo build", "deploy:aa": "turbo deploy --filter=antalmanac", "deploy:cdk": "turbo deploy --filter=cdk", - "deploy": "turbo deploy" + "deploy": "turbo deploy", + "test": "vitest" }, "devDependencies": { "cross-env": "^7.0.3", "husky": "^8.0.3", "lint-staged": "^13.1.1", "prettier": "^2.8.4", - "turbo": "latest" + "turbo": "latest", + "vitest": "^0.34.2" }, "packageManager": "pnpm@8.6.0", "engines": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a35b33414..fc80504bf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: turbo: specifier: latest version: 1.10.13 + vitest: + specifier: ^0.34.2 + version: 0.34.2 apps/antalmanac: dependencies: @@ -70,10 +73,10 @@ importers: version: 4.24.10(react-dom@18.2.0)(react@18.2.0) '@trpc/client': specifier: ^10.30.0 - version: 10.30.0(@trpc/server@10.23.0) + version: 10.30.0(@trpc/server@10.30.0) '@trpc/server': - specifier: ^10.23.0 - version: 10.23.0 + specifier: ^10.30.0 + version: 10.30.0 chart.js: specifier: ^4.2.1 version: 4.2.1 @@ -265,8 +268,8 @@ importers: specifier: '*' version: link:../../packages/types '@trpc/server': - specifier: ^10.23.0 - version: 10.23.0 + specifier: ^10.30.0 + version: 10.30.0 '@vendia/serverless-express': specifier: ^4.10.1 version: 4.10.1 @@ -2882,16 +2885,16 @@ packages: use-sync-external-store: 1.2.0(react@18.2.0) dev: false - /@trpc/client@10.30.0(@trpc/server@10.23.0): + /@trpc/client@10.30.0(@trpc/server@10.30.0): resolution: {integrity: sha512-utz0qRI4eU3QcHvBwcSONEnt5pWR3Dyk4VFJnySHysBT6GQRRpJifWX5+RxDhFK93LxcAmiirFbYXjZ40gbobw==} peerDependencies: '@trpc/server': 10.30.0 dependencies: - '@trpc/server': 10.23.0 + '@trpc/server': 10.30.0 dev: false - /@trpc/server@10.23.0: - resolution: {integrity: sha512-0CPfGZK3v3gfAL8ylV5vDJVBQtPp47EB4Vgx6WItSXmwYQlLC39ZC3gkZ4I5cGpQKl4l3QrNNeUvIcse4hlMxA==} + /@trpc/server@10.30.0: + resolution: {integrity: sha512-pRsrHCuar3fbyOdJvO4b80OMP1Tx/wOSy5Ozy6cFDFWVUmfAyIX3En5Hoysy4cmMUuCsQsfTEYQwo+OcpjzBkg==} dev: false /@types/aws-lambda@8.10.110: diff --git a/vitest.workspace.ts b/vitest.workspace.ts new file mode 100644 index 000000000..3d2ac9614 --- /dev/null +++ b/vitest.workspace.ts @@ -0,0 +1,8 @@ +import { defineWorkspace } from 'vitest/config'; + +export default defineWorkspace([ + { + root: 'apps/antalmanac', + extends: 'apps/antalmanac/vite.config.ts', + }, +]); From c50fc9f5f487a06fdba7a188114a956439bfcce8 Mon Sep 17 00:00:00 2001 From: Kevin Wu Date: Wed, 6 Sep 2023 07:52:00 -0700 Subject: [PATCH 37/89] Fix helper function: removeDuplicateMeetings (#688) --- apps/antalmanac/src/lib/helpers.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/antalmanac/src/lib/helpers.ts b/apps/antalmanac/src/lib/helpers.ts index 27634c83a..9a34e6546 100644 --- a/apps/antalmanac/src/lib/helpers.ts +++ b/apps/antalmanac/src/lib/helpers.ts @@ -196,7 +196,9 @@ function removeDuplicateMeetings(websocResp: WebsocAPIResponse): WebsocAPIRespon for (let i = 0; i < existingMeetings.length; i++) { const sameDayAndTime = - meeting.days === existingMeetings[i].days && meeting.time === existingMeetings[i].time; + meeting.days === existingMeetings[i].days && + meeting.startTime === existingMeetings[i].startTime && + meeting.endTime === existingMeetings[i].endTime; const sameBuilding = meeting.bldg === existingMeetings[i].bldg; //This shouldn't be possible because there shouldn't be duplicate locations in a section @@ -208,8 +210,10 @@ function removeDuplicateMeetings(websocResp: WebsocAPIResponse): WebsocAPIRespon // Add the building to existing meeting instead of creating a new one if (sameDayAndTime && !sameBuilding) { existingMeetings[i] = { + timeIsTBA: existingMeetings[i].timeIsTBA, days: existingMeetings[i].days, - time: existingMeetings[i].time, + startTime: existingMeetings[i].startTime, + endTime: existingMeetings[i].endTime, bldg: [existingMeetings[i].bldg + ' & ' + meeting.bldg], }; isNewMeeting = false; From 7565b6183b43204d854b98053a3111063edc41d1 Mon Sep 17 00:00:00 2001 From: MinhxNguyen7 Date: Thu, 7 Sep 2023 12:24:09 +0700 Subject: [PATCH 38/89] Switch to staging-88 PP GraphQL endpoint --- apps/antalmanac/src/lib/api/endpoints.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/antalmanac/src/lib/api/endpoints.ts b/apps/antalmanac/src/lib/api/endpoints.ts index 3cb2298e0..48d49442b 100644 --- a/apps/antalmanac/src/lib/api/endpoints.ts +++ b/apps/antalmanac/src/lib/api/endpoints.ts @@ -15,7 +15,7 @@ export const LOOKUP_NOTIFICATIONS_ENDPOINT = endpointTransform('/api/notificatio export const REGISTER_NOTIFICATIONS_ENDPOINT = endpointTransform('/api/notifications/registerNotifications'); // PeterPortal API -export const PETERPORTAL_GRAPHQL_ENDPOINT = 'https://api-next.peterportal.org/v1/graphql'; +export const PETERPORTAL_GRAPHQL_ENDPOINT = 'https://staging-88.api-next.peterportal.org/v1/graphql'; export const PETERPORTAL_REST_ENDPOINT = 'https://api-next.peterportal.org/v1/rest'; export const PETERPORTAL_WEBSOC_ENDPOINT = `${PETERPORTAL_REST_ENDPOINT}/websoc`; From e13cbd5504a9d47caf5ef5250f4edfbe3fdc6d55 Mon Sep 17 00:00:00 2001 From: MinhxNguyen7 Date: Thu, 7 Sep 2023 12:30:35 +0700 Subject: [PATCH 39/89] Generalize graphQL query function --- apps/antalmanac/src/lib/helpers.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/antalmanac/src/lib/helpers.ts b/apps/antalmanac/src/lib/helpers.ts index 9a34e6546..66fa0e0d2 100644 --- a/apps/antalmanac/src/lib/helpers.ts +++ b/apps/antalmanac/src/lib/helpers.ts @@ -15,7 +15,7 @@ interface GradesGraphQLResponse { }; } -export async function queryGraphQL(queryString: string): Promise { +export async function queryGraphQL(queryString: string): Promise { const query = JSON.stringify({ query: queryString, }); @@ -33,7 +33,7 @@ export async function queryGraphQL(queryString: string): Promise; + return json as Promise; } export interface CourseDetails { deptCode: string; @@ -290,7 +290,7 @@ export async function queryGrades(deptCode: string, courseNumber: string, instru }, }`; - const resp = await queryGraphQL(queryString); + const resp = await queryGraphQL(queryString); gradesCache[cacheKey] = resp?.data?.aggregateGrades?.gradeDistribution; return gradesCache[cacheKey] as Grades; From 06f594096ecb230712fc13286f3fbca22493a23a Mon Sep 17 00:00:00 2001 From: MinhxNguyen7 Date: Thu, 7 Sep 2023 12:44:52 +0700 Subject: [PATCH 40/89] Rename variables loadCourses for descriptiveness --- .../RightPane/CoursePane/CourseRenderPane.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/antalmanac/src/components/RightPane/CoursePane/CourseRenderPane.tsx b/apps/antalmanac/src/components/RightPane/CoursePane/CourseRenderPane.tsx index ebdc7f647..98ea46839 100644 --- a/apps/antalmanac/src/components/RightPane/CoursePane/CourseRenderPane.tsx +++ b/apps/antalmanac/src/components/RightPane/CoursePane/CourseRenderPane.tsx @@ -214,7 +214,7 @@ class CourseRenderPane extends PureComponent { const formData = RightPaneStore.getFormData(); - const params = { + const websocQueryParams = { department: formData.deptValue, term: formData.term, ge: formData.ge, @@ -231,16 +231,16 @@ class CourseRenderPane extends PureComponent Date: Thu, 7 Sep 2023 13:38:53 +0700 Subject: [PATCH 41/89] Implement aggregateGroupedGrades endpoint --- apps/antalmanac/src/lib/helpers.ts | 76 +++++++++++++++++++++++++++++- 1 file changed, 74 insertions(+), 2 deletions(-) diff --git a/apps/antalmanac/src/lib/helpers.ts b/apps/antalmanac/src/lib/helpers.ts index 66fa0e0d2..c9d17ab20 100644 --- a/apps/antalmanac/src/lib/helpers.ts +++ b/apps/antalmanac/src/lib/helpers.ts @@ -1,6 +1,6 @@ import React from 'react'; -import { WebsocSectionMeeting, WebsocSection, WebsocAPIResponse } from 'peterportal-api-next-types'; +import { WebsocSectionMeeting, WebsocSection, WebsocAPIResponse, GE } from 'peterportal-api-next-types'; import { PETERPORTAL_GRAPHQL_ENDPOINT, PETERPORTAL_WEBSOC_ENDPOINT } from './api/endpoints'; import { addCourse, openSnackbar } from '$actions/AppStoreActions'; import AppStore from '$stores/AppStore'; @@ -15,6 +15,12 @@ interface GradesGraphQLResponse { }; } +export interface GroupedGradesGraphQLResponse { + data: { + aggregateGroupedGrades: Array; + }; +} + export async function queryGraphQL(queryString: string): Promise { const query = JSON.stringify({ query: queryString, @@ -109,7 +115,8 @@ const websocCache: { [key: string]: CacheEntry } = {}; export function clearCache() { Object.keys(websocCache).forEach((key) => delete websocCache[key]); //https://stackoverflow.com/a/19316873/14587004 - Object.keys(gradesCache).forEach((key) => delete gradesCache[key]); //https://stackoverflow.com/a/19316873/14587004 + Object.keys(gradesCache).forEach((key) => delete gradesCache[key]); + cachedGradeQueries.clear(); } function cleanParams(record: Record) { @@ -245,12 +252,77 @@ export interface Grades { gradeNPCount: number; } +export interface CourseInstructorGrades extends Grades { + department: string; + courseNumber: string; + instructor: string; +} + // null means that the request failed // undefined means that the request is in progress const gradesCache: { [key: string]: Grades | null | undefined } = {}; +// Grades queries that have been cached +// We need this because gradesCache destructures the data and doesn't retain whether we looked at one course or a whole department/GE +const cachedGradeQueries = new Set(); + +export interface PopulateGradesCacheParams { + department?: string; + ge?: GE; +} + +/* + * Query the PeterPortal GraphQL API (aggregrateGroupedGrades) for the grades of all course-instructor. + * This should be done before queryGrades to avoid DoS'ing the server + * + * Either department or ge must be provided + * + * @param department The department code of the course. + * @param courseNumber The course number of the course. + * @param ge The GE filter + */ +export async function populateGradesCache({ department, ge }: PopulateGradesCacheParams): Promise { + if (!department && !ge) throw new Error('populategradesCache: Must provide either department or ge'); + + const queryKey = `${department ?? ''}${ge ?? ''}`; + + // If the whole query has already been cached, return + if (queryKey in cachedGradeQueries) return; + + const filter = `${ge ? `ge: ${ge} ` : ''}${department ? `department: "${department}" ` : ''}`; + + const response = await queryGraphQL(`{ + aggregateGroupedGrades(${filter}) { + department + courseNumber + instructor + averageGPA + gradeACount + gradeBCount + gradeCCount + gradeDCount + gradeFCount + gradeNPCount + gradePCount + } + }`); + + const groupedGrades = response?.data?.aggregateGroupedGrades; + + if (!groupedGrades) throw new Error('populateGradesCache: Failed to query GraphQL'); + + // Populate cache + for (const course of groupedGrades) { + const cacheKey = `${course.department}${course.courseNumber}${course.instructor}`; + gradesCache[cacheKey] = course as Grades; + } + + cachedGradeQueries.add(queryKey); +} + /* * Query the PeterPortal GraphQL API for a course's grades with caching + * This should NOT be done individually and independantly to fetch large amounts of data. Use populateGradesCache first to avoid DoS'ing the server * * @param deptCode The department code of the course. * @param courseNumber The course number of the course. From 71863c6ea5f3d859a0b417a2af15cabd3419989b Mon Sep 17 00:00:00 2001 From: MinhxNguyen7 Date: Thu, 7 Sep 2023 13:56:08 +0700 Subject: [PATCH 42/89] Move grades to separate module and class --- .../RightPane/SectionTable/GradesPopup.tsx | 5 +- .../SectionTable/SectionTableBody.tsx | 5 +- apps/antalmanac/src/lib/grades.ts | 151 ++++++++++++++++++ apps/antalmanac/src/lib/helpers.ts | 145 +---------------- 4 files changed, 159 insertions(+), 147 deletions(-) create mode 100644 apps/antalmanac/src/lib/grades.ts diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/GradesPopup.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/GradesPopup.tsx index 7f25df522..fb398ec2b 100644 --- a/apps/antalmanac/src/components/RightPane/SectionTable/GradesPopup.tsx +++ b/apps/antalmanac/src/components/RightPane/SectionTable/GradesPopup.tsx @@ -5,7 +5,8 @@ import { Skeleton } from '@material-ui/lab'; import { useState } from 'react'; import { Bar, BarChart, CartesianGrid, ResponsiveContainer, XAxis, YAxis } from 'recharts'; -import { isDarkMode, queryGrades } from '$lib/helpers'; +import { isDarkMode } from '$lib/helpers'; +import Grades from '$lib/grades'; const styles: Styles = { button: { @@ -53,7 +54,7 @@ const GradesPopup = ({ deptCode, courseNumber, instructor = '', classes, isMobil } try { - const courseGrades = await queryGrades(deptCode, courseNumber, instructor); + const courseGrades = await Grades.queryGrades(deptCode, courseNumber, instructor); if (!courseGrades) { setLoading(false); setGraphTitle('Grades are not available for this class.'); diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx index 7b680361b..17f02bd34 100644 --- a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx +++ b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx @@ -28,7 +28,8 @@ import { ColorAndDelete, ScheduleAddCell } from './SectionTableButtons'; import restrictionsMapping from './static/restrictionsMapping.json'; import GradesPopup from './GradesPopup'; import analyticsEnum, { logAnalytics } from '$lib/analytics'; -import { clickToCopy, CourseDetails, isDarkMode, queryGrades } from '$lib/helpers'; +import { clickToCopy, CourseDetails, isDarkMode } from '$lib/helpers'; +import Grades from '$lib/Grades'; import AppStore from '$stores/AppStore'; import { mobileContext } from '$components/MobileHome'; import locationIds from '$lib/location_ids'; @@ -217,7 +218,7 @@ const GPACell = withStyles(styles)((props: GPACellProps) => { const loadGpa = async (deptCode: string, courseNumber: string, instructors: string[]) => { // Get the GPA of the first instructor of this section where data exists for (const instructor of instructors.filter((instructor) => instructor !== 'STAFF')) { - const grades = await queryGrades(deptCode, courseNumber, instructor); + const grades = await Grades.queryGrades(deptCode, courseNumber, instructor); if (grades?.averageGPA) { setGpa(grades.averageGPA.toFixed(2).toString()); diff --git a/apps/antalmanac/src/lib/grades.ts b/apps/antalmanac/src/lib/grades.ts new file mode 100644 index 000000000..517ae8ccb --- /dev/null +++ b/apps/antalmanac/src/lib/grades.ts @@ -0,0 +1,151 @@ +import { GE } from 'peterportal-api-next-types'; +import { queryGraphQL } from './helpers'; + +export interface Grades { + averageGPA: number; + gradeACount: number; + gradeBCount: number; + gradeCCount: number; + gradeDCount: number; + gradeFCount: number; + gradePCount: number; + gradeNPCount: number; +} + +export interface CourseInstructorGrades extends Grades { + department: string; + courseNumber: string; + instructor: string; +} + +export interface GradesGraphQLResponse { + data: { + aggregateGrades: { + gradeDistribution: Grades; + }; + }; +} + +export interface GroupedGradesGraphQLResponse { + data: { + aggregateGroupedGrades: Array; + }; +} + +/** + * Class to handle querying and caching of grades. + * Retrieves grades from the PeterPortal GraphQL API. + */ +export class _Grades { + // null means that the request failed + // undefined means that the request is in progress + gradesCache: { [key: string]: Grades | null | undefined } = {}; + + // Grades queries that have been cached + // We need this because gradesCache destructures the data and doesn't retain whether we looked at one course or a whole department/GE + cachedQueries = new Set(); + + clearCache() { + Object.keys(this.gradesCache).forEach((key) => delete this.gradesCache[key]); //https://stackoverflow.com/a/19316873/14587004 + this.cachedQueries = new Set(); + } + + /* + * Query the PeterPortal GraphQL API (aggregrateGroupedGrades) for the grades of all course-instructor. + * This should be done before queryGrades to avoid DoS'ing the server + * + * Either department or ge must be provided + * + * @param department The department code of the course. + * @param courseNumber The course number of the course. + * @param ge The GE filter + */ + populateGradesCache = async ({ department, ge }: { department?: string; ge?: GE }): Promise => { + if (!department && !ge) throw new Error('populategradesCache: Must provide either department or ge'); + + const queryKey = `${department ?? ''}${ge ?? ''}`; + + // If the whole query has already been cached, return + if (queryKey in this.cachedQueries) return; + + const filter = `${ge ? `ge: ${ge} ` : ''}${department ? `department: "${department}" ` : ''}`; + + const response = await queryGraphQL(`{ + aggregateGroupedGrades(${filter}) { + department + courseNumber + instructor + averageGPA + gradeACount + gradeBCount + gradeCCount + gradeDCount + gradeFCount + gradeNPCount + gradePCount + } + }`); + + const groupedGrades = response?.data?.aggregateGroupedGrades; + + if (!groupedGrades) throw new Error('populateGradesCache: Failed to query GraphQL'); + + // Populate cache + for (const course of groupedGrades) { + const cacheKey = `${course.department}${course.courseNumber}${course.instructor}`; + this.gradesCache[cacheKey] = course as Grades; + } + + this.cachedQueries.add(queryKey); + }; + + /* + * Query the PeterPortal GraphQL API for a course's grades with caching + * This should NOT be done individually and independantly to fetch large amounts of data. Use populateGradesCache first to avoid DoS'ing the server + * + * @param deptCode The department code of the course. + * @param courseNumber The course number of the course. + * @param instructor The instructor's name (optional) + * + * @returns Grades + */ + queryGrades = async (deptCode: string, courseNumber: string, instructor = ''): Promise => { + instructor = instructor.replace('STAFF', '').trim(); // Ignore STAFF + const instructorFilter = instructor ? `instructor: "${instructor}"` : ''; + + const cacheKey = deptCode + courseNumber + instructor; + + // If cache is null, that request failed last time, and we try again + if (cacheKey in this.gradesCache && this.gradesCache[cacheKey] !== null) { + // If cache is undefined, there's a request in progress + while (this.gradesCache[cacheKey] === undefined) { + await new Promise((resolve) => setTimeout(resolve, 350)); // Wait before checking cache again + } + return this.gradesCache[cacheKey] as Grades; + } + + this.gradesCache[cacheKey] = undefined; // Set cache to undefined to indicate request in progress + + const queryString = `{ + aggregateGrades(department: "${deptCode}", courseNumber: "${courseNumber}", ${instructorFilter}) { + gradeDistribution { + gradeACount + gradeBCount + gradeCCount + gradeDCount + gradeFCount + gradePCount + gradeNPCount + averageGPA + } + }, + }`; + + const resp = await queryGraphQL(queryString); + this.gradesCache[cacheKey] = resp?.data?.aggregateGrades?.gradeDistribution; + + return this.gradesCache[cacheKey] as Grades; + }; +} + +export default new _Grades(); diff --git a/apps/antalmanac/src/lib/helpers.ts b/apps/antalmanac/src/lib/helpers.ts index c9d17ab20..2a0266335 100644 --- a/apps/antalmanac/src/lib/helpers.ts +++ b/apps/antalmanac/src/lib/helpers.ts @@ -2,25 +2,12 @@ import React from 'react'; import { WebsocSectionMeeting, WebsocSection, WebsocAPIResponse, GE } from 'peterportal-api-next-types'; import { PETERPORTAL_GRAPHQL_ENDPOINT, PETERPORTAL_WEBSOC_ENDPOINT } from './api/endpoints'; +import Grades from './grades'; import { addCourse, openSnackbar } from '$actions/AppStoreActions'; import AppStore from '$stores/AppStore'; import { RepeatingCustomEvent } from '$components/Calendar/Toolbar/CustomEventDialog/CustomEventDialog'; import trpc from '$lib/api/trpc'; -interface GradesGraphQLResponse { - data: { - aggregateGrades: { - gradeDistribution: Grades; - }; - }; -} - -export interface GroupedGradesGraphQLResponse { - data: { - aggregateGroupedGrades: Array; - }; -} - export async function queryGraphQL(queryString: string): Promise { const query = JSON.stringify({ query: queryString, @@ -115,8 +102,7 @@ const websocCache: { [key: string]: CacheEntry } = {}; export function clearCache() { Object.keys(websocCache).forEach((key) => delete websocCache[key]); //https://stackoverflow.com/a/19316873/14587004 - Object.keys(gradesCache).forEach((key) => delete gradesCache[key]); - cachedGradeQueries.clear(); + Grades.clearCache(); } function cleanParams(record: Record) { @@ -241,133 +227,6 @@ function removeDuplicateMeetings(websocResp: WebsocAPIResponse): WebsocAPIRespon return websocResp; } -export interface Grades { - averageGPA: number; - gradeACount: number; - gradeBCount: number; - gradeCCount: number; - gradeDCount: number; - gradeFCount: number; - gradePCount: number; - gradeNPCount: number; -} - -export interface CourseInstructorGrades extends Grades { - department: string; - courseNumber: string; - instructor: string; -} - -// null means that the request failed -// undefined means that the request is in progress -const gradesCache: { [key: string]: Grades | null | undefined } = {}; - -// Grades queries that have been cached -// We need this because gradesCache destructures the data and doesn't retain whether we looked at one course or a whole department/GE -const cachedGradeQueries = new Set(); - -export interface PopulateGradesCacheParams { - department?: string; - ge?: GE; -} - -/* - * Query the PeterPortal GraphQL API (aggregrateGroupedGrades) for the grades of all course-instructor. - * This should be done before queryGrades to avoid DoS'ing the server - * - * Either department or ge must be provided - * - * @param department The department code of the course. - * @param courseNumber The course number of the course. - * @param ge The GE filter - */ -export async function populateGradesCache({ department, ge }: PopulateGradesCacheParams): Promise { - if (!department && !ge) throw new Error('populategradesCache: Must provide either department or ge'); - - const queryKey = `${department ?? ''}${ge ?? ''}`; - - // If the whole query has already been cached, return - if (queryKey in cachedGradeQueries) return; - - const filter = `${ge ? `ge: ${ge} ` : ''}${department ? `department: "${department}" ` : ''}`; - - const response = await queryGraphQL(`{ - aggregateGroupedGrades(${filter}) { - department - courseNumber - instructor - averageGPA - gradeACount - gradeBCount - gradeCCount - gradeDCount - gradeFCount - gradeNPCount - gradePCount - } - }`); - - const groupedGrades = response?.data?.aggregateGroupedGrades; - - if (!groupedGrades) throw new Error('populateGradesCache: Failed to query GraphQL'); - - // Populate cache - for (const course of groupedGrades) { - const cacheKey = `${course.department}${course.courseNumber}${course.instructor}`; - gradesCache[cacheKey] = course as Grades; - } - - cachedGradeQueries.add(queryKey); -} - -/* - * Query the PeterPortal GraphQL API for a course's grades with caching - * This should NOT be done individually and independantly to fetch large amounts of data. Use populateGradesCache first to avoid DoS'ing the server - * - * @param deptCode The department code of the course. - * @param courseNumber The course number of the course. - * @param instructor The instructor's name (optional) - * - * @returns Grades - */ -export async function queryGrades(deptCode: string, courseNumber: string, instructor = ''): Promise { - instructor = instructor.replace('STAFF', '').trim(); // Ignore STAFF - const instructorFilter = instructor ? `instructor: "${instructor}"` : ''; - - const cacheKey = deptCode + courseNumber + instructor; - - // If cache is null, that request failed last time, and we try again - if (cacheKey in gradesCache && gradesCache[cacheKey] !== null) { - // If cache is undefined, there's a request in progress - while (gradesCache[cacheKey] === undefined) { - await new Promise((resolve) => setTimeout(resolve, 350)); // Wait before checking cache again - } - return gradesCache[cacheKey] as Grades; - } - - gradesCache[cacheKey] = undefined; // Set cache to undefined to indicate request in progress - - const queryString = `{ - aggregateGrades(department: "${deptCode}", courseNumber: "${courseNumber}", ${instructorFilter}) { - gradeDistribution { - gradeACount - gradeBCount - gradeCCount - gradeDCount - gradeFCount - gradePCount - gradeNPCount - averageGPA - } - }, - }`; - - const resp = await queryGraphQL(queryString); - gradesCache[cacheKey] = resp?.data?.aggregateGrades?.gradeDistribution; - - return gradesCache[cacheKey] as Grades; -} - export function combineSOCObjects(SOCObjects: WebsocAPIResponse[]) { const combined = SOCObjects.shift() as WebsocAPIResponse; for (const res of SOCObjects) { From 8bc5e053ff87c15a1fd5150aa7f11a2c429314b1 Mon Sep 17 00:00:00 2001 From: MinhxNguyen7 Date: Thu, 7 Sep 2023 15:32:19 +0700 Subject: [PATCH 43/89] cacheOnly for section table grades --- .../RightPane/CoursePane/CourseRenderPane.tsx | 20 ++++++--- .../RightPane/SectionTable/GradesPopup.tsx | 2 +- apps/antalmanac/src/lib/grades.ts | 42 ++++++++++--------- 3 files changed, 38 insertions(+), 26 deletions(-) diff --git a/apps/antalmanac/src/components/RightPane/CoursePane/CourseRenderPane.tsx b/apps/antalmanac/src/components/RightPane/CoursePane/CourseRenderPane.tsx index 98ea46839..ff5a61df8 100644 --- a/apps/antalmanac/src/components/RightPane/CoursePane/CourseRenderPane.tsx +++ b/apps/antalmanac/src/components/RightPane/CoursePane/CourseRenderPane.tsx @@ -18,6 +18,7 @@ import darkNoNothing from './static/dark-no_results.png'; import noNothing from './static/no_results.png'; import AppStore from '$stores/AppStore'; import { isDarkMode, queryWebsoc, queryWebsocMultiple } from '$lib/helpers'; +import Grades from '$lib/grades'; import analyticsEnum from '$lib/analytics'; const styles: Styles = (theme) => ({ @@ -230,13 +231,20 @@ class CourseRenderPane extends PureComponent; // Grades queries that have been cached // We need this because gradesCache destructures the data and doesn't retain whether we looked at one course or a whole department/GE - cachedQueries = new Set(); + cachedQueries: Set; + + constructor() { + this.gradesCache = {}; + this.cachedQueries = new Set(); + } clearCache() { Object.keys(this.gradesCache).forEach((key) => delete this.gradesCache[key]); //https://stackoverflow.com/a/19316873/14587004 @@ -109,22 +112,20 @@ export class _Grades { * * @returns Grades */ - queryGrades = async (deptCode: string, courseNumber: string, instructor = ''): Promise => { + queryGrades = async ( + deptCode: string, + courseNumber: string, + instructor = '', + cacheOnly = true + ): Promise => { instructor = instructor.replace('STAFF', '').trim(); // Ignore STAFF const instructorFilter = instructor ? `instructor: "${instructor}"` : ''; const cacheKey = deptCode + courseNumber + instructor; - // If cache is null, that request failed last time, and we try again - if (cacheKey in this.gradesCache && this.gradesCache[cacheKey] !== null) { - // If cache is undefined, there's a request in progress - while (this.gradesCache[cacheKey] === undefined) { - await new Promise((resolve) => setTimeout(resolve, 350)); // Wait before checking cache again - } - return this.gradesCache[cacheKey] as Grades; - } + if (cacheKey in this.gradesCache) return this.gradesCache[cacheKey]; - this.gradesCache[cacheKey] = undefined; // Set cache to undefined to indicate request in progress + if (cacheOnly) return null; const queryString = `{ aggregateGrades(department: "${deptCode}", courseNumber: "${courseNumber}", ${instructorFilter}) { @@ -141,11 +142,14 @@ export class _Grades { }, }`; - const resp = await queryGraphQL(queryString); - this.gradesCache[cacheKey] = resp?.data?.aggregateGrades?.gradeDistribution; + const resp = + (await queryGraphQL(queryString))?.data?.aggregateGrades?.gradeDistribution ?? null; + + if (resp) this.gradesCache[cacheKey] = resp; - return this.gradesCache[cacheKey] as Grades; + return resp; }; } -export default new _Grades(); +const grades = new _Grades(); +export default grades; From 625682c8d24497c9c3495bd3317f9e2dc77063cf Mon Sep 17 00:00:00 2001 From: MinhxNguyen7 Date: Thu, 7 Sep 2023 15:35:23 +0700 Subject: [PATCH 44/89] Fix typo in import statement --- .../src/components/RightPane/SectionTable/SectionTableBody.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx index 17f02bd34..6c7f66d7f 100644 --- a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx +++ b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx @@ -29,7 +29,7 @@ import restrictionsMapping from './static/restrictionsMapping.json'; import GradesPopup from './GradesPopup'; import analyticsEnum, { logAnalytics } from '$lib/analytics'; import { clickToCopy, CourseDetails, isDarkMode } from '$lib/helpers'; -import Grades from '$lib/Grades'; +import Grades from '$lib/grades'; import AppStore from '$stores/AppStore'; import { mobileContext } from '$components/MobileHome'; import locationIds from '$lib/location_ids'; From 09f1a3a810fc73e84994b9a7288886a32c1ca082 Mon Sep 17 00:00:00 2001 From: MinhxNguyen7 Date: Thu, 7 Sep 2023 15:44:25 +0700 Subject: [PATCH 45/89] Remove extraneous information from grades cache --- apps/antalmanac/src/lib/grades.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/antalmanac/src/lib/grades.ts b/apps/antalmanac/src/lib/grades.ts index c1297fe76..2abc8de7c 100644 --- a/apps/antalmanac/src/lib/grades.ts +++ b/apps/antalmanac/src/lib/grades.ts @@ -96,7 +96,16 @@ class _Grades { // Populate cache for (const course of groupedGrades) { const cacheKey = `${course.department}${course.courseNumber}${course.instructor}`; - this.gradesCache[cacheKey] = course as Grades; + this.gradesCache[cacheKey] = { + averageGPA: course.averageGPA, + gradeACount: course.gradeACount, + gradeBCount: course.gradeBCount, + gradeCCount: course.gradeCCount, + gradeDCount: course.gradeDCount, + gradeFCount: course.gradeFCount, + gradeNPCount: course.gradeNPCount, + gradePCount: course.gradePCount, + }; } this.cachedQueries.add(queryKey); From 736e505a8010bfa2a82c511aafa7b638af60f4c6 Mon Sep 17 00:00:00 2001 From: MinhxNguyen7 Date: Thu, 7 Sep 2023 15:52:56 +0700 Subject: [PATCH 46/89] Move Zotistics link to grades popup title --- .../RightPane/SectionTable/GradesPopup.tsx | 38 +++++++++---------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/GradesPopup.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/GradesPopup.tsx index 50282e7f2..64f832bd2 100644 --- a/apps/antalmanac/src/components/RightPane/SectionTable/GradesPopup.tsx +++ b/apps/antalmanac/src/components/RightPane/SectionTable/GradesPopup.tsx @@ -13,6 +13,9 @@ const styles: Styles = { backgroundColor: '#385EB1', color: '#fff', }, + gradesPopupOuterDiv: { + overflow: 'hidden', + }, gpaTitle: { marginTop: '.5rem', textAlign: 'center', @@ -20,14 +23,12 @@ const styles: Styles = { fontSize: '1.2rem', marginRight: '4rem', marginLeft: '4rem', + color: 'inherit', + textDecoration: 'none', }, skeleton: { padding: '4px', }, - graphAnchor: { - cursor: 'pointer', - overflow: 'hidden', - }, }; interface GradesPopupProps { @@ -98,26 +99,25 @@ const GradesPopup = ({ deptCode, courseNumber, instructor = '', classes, isMobil const axisColor = isDarkMode() ? '#fff' : '#111'; return ( -
-
{graphTitle}
+
- {' '} - {gradeData && ( - - - - - - - - - )} - + {graphTitle} + {' '} + {gradeData && ( + + + + + + + + + )}
); } From 72e7f43ae14f2fca96fc53d77602b9ef6aba2005 Mon Sep 17 00:00:00 2001 From: MinhxNguyen7 Date: Thu, 7 Sep 2023 21:25:34 +0700 Subject: [PATCH 47/89] Refactor and set section table styling --- .../RightPane/SectionTable/SectionTable.tsx | 122 ++++++++++++------ 1 file changed, 79 insertions(+), 43 deletions(-) diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTable.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTable.tsx index 4e91e0a3b..e61543019 100644 --- a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTable.tsx +++ b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTable.tsx @@ -1,8 +1,9 @@ import { + Box, Paper, Table, - TableBody, TableCell, + TableBody, TableContainer, TableHead, TableRow, @@ -42,18 +43,80 @@ const styles = { scheduleNoteContainer: {}, }; -const tableHeaderColumns: Record = { - sectionCode: 'Code', - sectionDetails: 'Type', - instructors: 'Instructors', - gpa: 'GPA', - dayAndTime: 'Times', - location: 'Places', - sectionEnrollment: 'Enrollment', - restrictions: 'Restr', - status: 'Status', +interface TableHeaderColumnDetails { + label: string; + width?: string; +} + +const tableHeaderColumns: Record = { + sectionCode: { + label: 'Code', + width: '8%', + }, + sectionDetails: { + label: 'Type', + width: '8%', + }, + instructors: { + label: 'Instructors', + width: '15%', + }, + gpa: { + label: 'GPA', + width: '7%', + }, + dayAndTime: { + label: 'Times', + width: '10%', + }, + location: { + label: 'Places', + width: '10%', + }, + sectionEnrollment: { + label: 'Enrollment', + width: '9%', + }, + restrictions: { + label: 'Restr', + width: '10%', + }, + status: { + label: 'Status', + width: '8%', + }, }; +interface EnrollmentColumnHeaderProps { + label: string; + width?: string; +} + +function EnrollmentColumnHeader(props: EnrollmentColumnHeaderProps) { + const isMobileScreen = useMediaQuery(`(max-width: ${MOBILE_BREAKPOINT}`); + + return ( + + {props.label} + {!isMobileScreen && ( + + Enrolled/Capacity +
+ Waitlist +
+ New-Only Reserved + + } + > + +
+ )} +
+ ); +} + const SectionTable = (props: SectionTableProps) => { const { classes, courseDetails, term, allowHighlight, scheduleNames, analyticsCategory } = props; const courseId = courseDetails.deptCode.replaceAll(' ', '') + courseDetails.courseNumber; @@ -138,38 +201,11 @@ const SectionTable = (props: SectionTableProps) => { {Object.entries(tableHeaderColumns) .filter(([column]) => activeColumns.includes(column as SectionTableColumn)) - .map(([column, label]) => { - return ( - - {label !== 'Enrollment' ? ( - label - ) : ( -
- {label} - {!isMobileScreen && ( - - Enrolled/Capacity -
- Waitlist -
- New-Only Reserved - - } - > - -
- )} -
- )} -
- ); - })} + .map(([column, { label, width }]) => ( + + {label === 'Enrollment' ? : label} + + ))} From 77c1e4ebff7eb112447753fb54b9471461c0b4f1 Mon Sep 17 00:00:00 2001 From: MinhxNguyen7 Date: Thu, 7 Sep 2023 21:31:54 +0700 Subject: [PATCH 48/89] Fix bulk cache population cache hit --- apps/antalmanac/src/lib/grades.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/antalmanac/src/lib/grades.ts b/apps/antalmanac/src/lib/grades.ts index 2abc8de7c..d2561caaa 100644 --- a/apps/antalmanac/src/lib/grades.ts +++ b/apps/antalmanac/src/lib/grades.ts @@ -54,7 +54,7 @@ class _Grades { } /* - * Query the PeterPortal GraphQL API (aggregrateGroupedGrades) for the grades of all course-instructor. + * Query the PeterPortal GraphQL API (aggregrateGroupedGrades) for the grades of all course-instructor if not already cached. * This should be done before queryGrades to avoid DoS'ing the server * * Either department or ge must be provided @@ -69,7 +69,7 @@ class _Grades { const queryKey = `${department ?? ''}${ge ?? ''}`; // If the whole query has already been cached, return - if (queryKey in this.cachedQueries) return; + if (this.cachedQueries.has(queryKey)) return; const filter = `${ge ? `ge: ${ge} ` : ''}${department ? `department: "${department}" ` : ''}`; From 97e6feaee041ff8ae7e80499bb7edca4414ceca1 Mon Sep 17 00:00:00 2001 From: Aponia Date: Thu, 7 Sep 2023 07:54:24 -0700 Subject: [PATCH 49/89] Refactor gpa (#689) --- .../RightPane/SectionTable/GradesPopup.tsx | 211 +++++++++--------- .../RightPane/SectionTable/SectionTable.tsx | 121 +++++----- .../SectionTable/SectionTableBody.tsx | 98 ++++---- 3 files changed, 225 insertions(+), 205 deletions(-) diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/GradesPopup.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/GradesPopup.tsx index 64f832bd2..8e7902f33 100644 --- a/apps/antalmanac/src/components/RightPane/SectionTable/GradesPopup.tsx +++ b/apps/antalmanac/src/components/RightPane/SectionTable/GradesPopup.tsx @@ -1,126 +1,137 @@ -import { Theme } from '@material-ui/core'; -import { withStyles } from '@material-ui/core/styles'; -import { ClassNameMap, Styles } from '@material-ui/core/styles/withStyles'; -import { Skeleton } from '@material-ui/lab'; -import { useState } from 'react'; +import { useState, useEffect, useMemo } from 'react'; import { Bar, BarChart, CartesianGrid, ResponsiveContainer, XAxis, YAxis } from 'recharts'; - +import { Box, Link, Typography, Skeleton } from '@mui/material'; import { isDarkMode } from '$lib/helpers'; -import Grades from '$lib/grades'; - -const styles: Styles = { - button: { - backgroundColor: '#385EB1', - color: '#fff', - }, - gradesPopupOuterDiv: { - overflow: 'hidden', - }, - gpaTitle: { - marginTop: '.5rem', - textAlign: 'center', - fontWeight: 500, - fontSize: '1.2rem', - marginRight: '4rem', - marginLeft: '4rem', - color: 'inherit', - textDecoration: 'none', - }, - skeleton: { - padding: '4px', - }, -}; - -interface GradesPopupProps { +import GradesHelper, { type Grades } from '$lib/grades'; + +export interface GradeData { + grades: { + name: string; + all: number; + }[]; + courseGrades: Grades; +} + +async function getGradeData( + deptCode: string, + courseNumber: string, + instructor: string +): Promise { + const courseGrades = await GradesHelper.queryGrades(deptCode, courseNumber, instructor).catch((e) => { + console.log(e); + return undefined; + }); + + if (!courseGrades) { + return undefined; + } + + /** + * Format data for displayiing in chart. + * + * @example { sum_grade_a_count: 10, sum_grade_b_count: 20 } + */ + const grades = Object.entries(courseGrades) + .filter(([key]) => key !== 'averageGPA') + .map(([key, value]) => { + return { + name: key.replace('grade', '').replace('Count', ''), + all: value, + }; + }); + + return { grades, courseGrades }; +} + +export interface GradesPopupProps { deptCode: string; courseNumber: string; instructor?: string; - classes: ClassNameMap; isMobileScreen: boolean; } -interface GradeData { - name: string; - all: number; -} +function GradesPopup(props: GradesPopupProps) { + const { deptCode, courseNumber, instructor = '', isMobileScreen } = props; -const GradesPopup = ({ deptCode, courseNumber, instructor = '', classes, isMobileScreen }: GradesPopupProps) => { const [loading, setLoading] = useState(true); - const [graphTitle, setGraphTitle] = useState(null); - const [gradeData, setGradeData] = useState(null); - const loadGrades = async () => { + const [gradeData, setGradeData] = useState(); + + const width = useMemo(() => (isMobileScreen ? 300 : 500), [isMobileScreen]); + + const height = useMemo(() => (isMobileScreen ? 200 : 300), [isMobileScreen]); + + const graphTitle = useMemo(() => { + return gradeData + ? `Grade Distribution | Average GPA: ${gradeData.courseGrades.averageGPA.toFixed(2)}` + : 'Grades are not available for this class.'; + }, [gradeData]); + + useEffect(() => { if (loading === false) { return; } - try { - const courseGrades = await Grades.queryGrades(deptCode, courseNumber, instructor, false); - if (!courseGrades) { - setLoading(false); - setGraphTitle('Grades are not available for this class.'); - return; - } - - const data = []; - for (const [key, value] of Object.entries(courseGrades)) { - // format data for display in chart - // key formatting: sum_grade_a_count -> A - if (key !== 'averageGPA') { - data.push({ name: key.replace('grade', '').replace('Count', ''), all: value as number }); - } + getGradeData(deptCode, courseNumber, instructor).then((result) => { + if (result) { + setGradeData(result); } - - setGraphTitle(`Grade Distribution | Average GPA: ${courseGrades.averageGPA.toFixed(2)}`); - setGradeData(data); - setLoading(false); - } catch (e) { - console.log(e); setLoading(false); - setGraphTitle('Grades are not available for this class.'); - } - }; - - const width = isMobileScreen ? 300 : 500; - const height = isMobileScreen ? 200 : 300; - - void loadGrades(); + }); + }, [loading, deptCode, courseNumber, instructor]); if (loading) { return ( -
-

- -

-
+ + + ); - } else { - const encodedDept = encodeURIComponent(deptCode); - const axisColor = isDarkMode() ? '#fff' : '#111'; + } + if (!gradeData) { return ( -
- - {graphTitle} - {' '} - {gradeData && ( - - - - - - - - - )} -
+ + + No data available. + + ); } -}; -export default withStyles(styles)(GradesPopup); + const encodedDept = encodeURIComponent(deptCode); + const axisColor = isDarkMode() ? '#fff' : '#111'; + + return ( + + + {graphTitle} + + + + + + + + + + + + + ); +} + +export default GradesPopup; diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTable.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTable.tsx index e61543019..e10d993db 100644 --- a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTable.tsx +++ b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTable.tsx @@ -1,21 +1,18 @@ +import { useEffect, useMemo, useState } from 'react'; import { Box, Paper, Table, - TableCell, TableBody, + TableCell, TableContainer, TableHead, TableRow, Tooltip, Typography, useMediaQuery, -} from '@material-ui/core'; -import { withStyles } from '@material-ui/core/styles'; -import { Assessment, Help, RateReview } from '@material-ui/icons'; -import ShowChartIcon from '@material-ui/icons/ShowChart'; -// import AlmanacGraph from '../EnrollmentGraph/EnrollmentGraph'; uncomment when we get past enrollment data back and restore the files (https://github.com/icssc/AntAlmanac/tree/5e89e035e66f00608042871d43730ba785f756b0/src/components/RightPane/SectionTable/EnrollmentGraph) -import { useCallback, useEffect, useState } from 'react'; +} from '@mui/material'; +import { Assessment, Help, RateReview, ShowChart as ShowChartIcon } from '@mui/icons-material'; import { MOBILE_BREAKPOINT } from '../../../globals'; import RightPaneStore, { SECTION_TABLE_COLUMNS, type SectionTableColumn } from '../RightPaneStore'; import CourseInfoBar from './CourseInfoBar'; @@ -25,23 +22,10 @@ import { SectionTableProps } from './SectionTable.types'; import SectionTableBody from './SectionTableBody'; import analyticsEnum from '$lib/analytics'; -const styles = { - flex: { - display: 'flex', - alignItems: 'center', - }, - iconMargin: { - marginRight: '4px', - }, - cellPadding: { - padding: '0px 0px 0px 0px', - }, - row: {}, - container: {}, - titleRow: {}, - clearSchedule: {}, - scheduleNoteContainer: {}, -}; +const TOTAL_NUM_COLUMNS = SECTION_TABLE_COLUMNS.length; + +// uncomment when we get past enrollment data back and restore the files (https://github.com/icssc/AntAlmanac/tree/5e89e035e66f00608042871d43730ba785f756b0/src/components/RightPane/SectionTable/EnrollmentGraph) +// import AlmanacGraph from '../EnrollmentGraph/EnrollmentGraph'; interface TableHeaderColumnDetails { label: string; @@ -51,11 +35,11 @@ interface TableHeaderColumnDetails { const tableHeaderColumns: Record = { sectionCode: { label: 'Code', - width: '8%', + width: '10%', }, sectionDetails: { label: 'Type', - width: '8%', + width: '10%', }, instructors: { label: 'Instructors', @@ -63,11 +47,11 @@ const tableHeaderColumns: Record = }, gpa: { label: 'GPA', - width: '7%', + width: '5%', }, dayAndTime: { label: 'Times', - width: '10%', + width: '15%', }, location: { label: 'Places', @@ -75,7 +59,7 @@ const tableHeaderColumns: Record = }, sectionEnrollment: { label: 'Enrollment', - width: '9%', + width: '10%', }, restrictions: { label: 'Restr', @@ -83,20 +67,21 @@ const tableHeaderColumns: Record = }, status: { label: 'Status', - width: '8%', + width: '10%', }, }; +const tableHeaderColumnEntries = Object.entries(tableHeaderColumns); + interface EnrollmentColumnHeaderProps { label: string; - width?: string; } function EnrollmentColumnHeader(props: EnrollmentColumnHeaderProps) { const isMobileScreen = useMediaQuery(`(max-width: ${MOBILE_BREAKPOINT}`); return ( - + {props.label} {!isMobileScreen && ( } > - + )} ); } -const SectionTable = (props: SectionTableProps) => { - const { classes, courseDetails, term, allowHighlight, scheduleNames, analyticsCategory } = props; - const courseId = courseDetails.deptCode.replaceAll(' ', '') + courseDetails.courseNumber; - const encodedDept = encodeURIComponent(courseDetails.deptCode); - const isMobileScreen = useMediaQuery(`(max-width: ${MOBILE_BREAKPOINT}`); +function SectionTable(props: SectionTableProps) { + const { courseDetails, term, allowHighlight, scheduleNames, analyticsCategory } = props; const [activeColumns, setActiveColumns] = useState(RightPaneStore.getActiveColumns()); - const handleColumnChange = useCallback( - (newActiveColumns: SectionTableColumn[]) => { - setActiveColumns(newActiveColumns); - }, - [setActiveColumns] - ); + const isMobileScreen = useMediaQuery(`(max-width: ${MOBILE_BREAKPOINT}`); + + const courseId = useMemo(() => { + return courseDetails.deptCode.replaceAll(' ', '') + courseDetails.courseNumber; + }, [courseDetails.deptCode, courseDetails.courseNumber]); + + const encodedDept = useMemo(() => { + return encodeURIComponent(courseDetails.deptCode); + }, [courseDetails.deptCode]); + + /** + * Limit table width to force side scrolling. + */ + const tableMinWidth = useMemo(() => { + const width = isMobileScreen ? 600 : 780; + const numActiveColumns = activeColumns.length; + return (width * numActiveColumns) / TOTAL_NUM_COLUMNS; + }, [isMobileScreen, activeColumns]); useEffect(() => { + const handleColumnChange = (newActiveColumns: SectionTableColumn[]) => { + setActiveColumns(newActiveColumns); + }; + RightPaneStore.on('columnChange', handleColumnChange); return () => { RightPaneStore.removeListener('columnChange', handleColumnChange); }; - }, [handleColumnChange]); - - // Limit table width to force side scrolling - const tableMinWidth = - ((isMobileScreen ? 600 : 780) * RightPaneStore.getActiveColumns().length) / SECTION_TABLE_COLUMNS.length; + }, []); return ( <> -
+ { icon={} redirectLink={`https://peterportal.org/course/${courseId}`} /> + { icon={} redirectLink={`https://zot-tracker.herokuapp.com/?dept=${encodedDept}&number=${courseDetails.courseNumber}&courseType=all`} /> -
+
- -
Final{finalExam}{finalExamString}
Color
Final{finalExamString}{finalExam}
ColorSection code - + className={classes.sectionCode} + label={sectionCode} + size="small" + />
Final{finalExam}{finalExamString}
Color
+ +
- - - {Object.entries(tableHeaderColumns) + + + + {tableHeaderColumnEntries .filter(([column]) => activeColumns.includes(column as SectionTableColumn)) .map(([column, { label, width }]) => ( - + {label === 'Enrollment' ? : label} ))} + {courseDetails.sections.map((section) => { return ( @@ -226,6 +217,6 @@ const SectionTable = (props: SectionTableProps) => { ); -}; +} -export default withStyles(styles)(SectionTable); +export default SectionTable; diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx index 6c7f66d7f..4b6080685 100644 --- a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx +++ b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx @@ -2,7 +2,6 @@ import { Box, Button, Chip, - ClickAwayListener, Popover, TableCell, TableRow, @@ -201,60 +200,79 @@ const InstructorsCell = withStyles(styles)((props: InstructorsCellProps) => { return {getLinks(instructors)}; }); +async function getGpaData(deptCode: string, courseNumber: string, instructors: string[]) { + const namedInstructors = instructors.filter((instructor) => instructor !== 'STAFF'); + + // Get the GPA of the first instructor of this section where data exists + for (const instructor of namedInstructors) { + const grades = await Grades.queryGrades(deptCode, courseNumber, instructor); + if (grades?.averageGPA) { + return { + gpa: grades.averageGPA.toFixed(2).toString(), + instructor: instructor, + }; + } + } + + return undefined; +} + interface GPACellProps { - classes: ClassNameMap; deptCode: string; courseNumber: string; instructors: string[]; } -const GPACell = withStyles(styles)((props: GPACellProps) => { - const { classes, deptCode, courseNumber, instructors } = props; +function GPACell(props: GPACellProps) { + const { deptCode, courseNumber, instructors } = props; - const [gpa, setGpa] = useState(''); - const [instructor, setInstructor] = useState(''); + const [gpa, setGpa] = useState(''); - useEffect(() => { - const loadGpa = async (deptCode: string, courseNumber: string, instructors: string[]) => { - // Get the GPA of the first instructor of this section where data exists - for (const instructor of instructors.filter((instructor) => instructor !== 'STAFF')) { - const grades = await Grades.queryGrades(deptCode, courseNumber, instructor); - - if (grades?.averageGPA) { - setGpa(grades.averageGPA.toFixed(2).toString()); - setInstructor(instructor); - return; - } - } - }; + const [instructor, setInstructor] = useState(''); - loadGpa(deptCode, courseNumber, instructors).catch(console.log); - }, [deptCode, courseNumber, instructors]); + const [anchorEl, setAnchorEl] = useState(); - const [anchorEl, setAnchorEl] = useState(null); + const handleClick = useCallback((event: React.MouseEvent) => { + setAnchorEl((currentAnchorEl) => (currentAnchorEl ? undefined : event.currentTarget)); + }, []); - const handleClick = (event: React.MouseEvent) => { - setAnchorEl(anchorEl ? null : event.currentTarget); - }; + const hideDistribution = useCallback(() => { + setAnchorEl(undefined); + }, []); - const hideDistribution = () => { - setAnchorEl(null); - }; + useEffect(() => { + getGpaData(deptCode, courseNumber, instructors) + .then((data) => { + if (data) { + setGpa(data.gpa); + setInstructor(data.instructor); + } + }) + .catch(console.log); + }, [deptCode, courseNumber, instructors]); return ( - // I don't know why the popover doesn't close on clickaway without the listener, but this does seem to be the usual recommendation - - - - - {gpa} - - - + + { isMobileScreen={useMediaQuery(`(max-width: ${MOBILE_BREAKPOINT}`)} /> - + ); -}); +} interface LocationsCellProps { classes: ClassNameMap; From 1424923f28fc5dda782a48296861a21f0bcb9871 Mon Sep 17 00:00:00 2001 From: Aponia Date: Thu, 7 Sep 2023 08:06:44 -0700 Subject: [PATCH 50/89] fix: styling issues --- .../src/components/RightPane/SectionTable/SectionTable.tsx | 6 +++--- .../components/RightPane/SectionTable/SectionTableBody.tsx | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTable.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTable.tsx index e10d993db..de098585c 100644 --- a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTable.tsx +++ b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTable.tsx @@ -11,8 +11,8 @@ import { Tooltip, Typography, useMediaQuery, -} from '@mui/material'; -import { Assessment, Help, RateReview, ShowChart as ShowChartIcon } from '@mui/icons-material'; +} from '@material-ui/core'; +import { Assessment, Help, RateReview, ShowChart as ShowChartIcon } from '@material-ui/icons'; import { MOBILE_BREAKPOINT } from '../../../globals'; import RightPaneStore, { SECTION_TABLE_COLUMNS, type SectionTableColumn } from '../RightPaneStore'; import CourseInfoBar from './CourseInfoBar'; @@ -140,7 +140,7 @@ function SectionTable(props: SectionTableProps) { return ( <> - + +
+ +
From 6dd7e40c31b9d3d529649fd6e2ddf929c300adfb Mon Sep 17 00:00:00 2001 From: MinhxNguyen7 Date: Thu, 7 Sep 2023 22:24:11 +0700 Subject: [PATCH 52/89] Fix GPA font size on mobile --- .../src/components/RightPane/SectionTable/SectionTableBody.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx index 200e9d502..a2747b2ac 100644 --- a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx +++ b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx @@ -259,7 +259,7 @@ function GPACell(props: GPACellProps) { padding: 0, minWidth: 0, fontWeight: 400, - fontSize: 16, + fontSize: '1rem', }} onClick={handleClick} variant="text" From be54eed291487d401621826fd0377bf3e58eaea0 Mon Sep 17 00:00:00 2001 From: MinhxNguyen7 Date: Thu, 7 Sep 2023 22:40:39 +0700 Subject: [PATCH 53/89] Fix average grade popup --- .../src/components/RightPane/SectionTable/GradesPopup.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/GradesPopup.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/GradesPopup.tsx index 8e7902f33..1b5acb428 100644 --- a/apps/antalmanac/src/components/RightPane/SectionTable/GradesPopup.tsx +++ b/apps/antalmanac/src/components/RightPane/SectionTable/GradesPopup.tsx @@ -17,8 +17,8 @@ async function getGradeData( courseNumber: string, instructor: string ): Promise { - const courseGrades = await GradesHelper.queryGrades(deptCode, courseNumber, instructor).catch((e) => { - console.log(e); + const courseGrades = await GradesHelper.queryGrades(deptCode, courseNumber, instructor, false).catch((e) => { + console.error(e); return undefined; }); From 7c3a53f7ef6b109d7444486b62dcde17220570fe Mon Sep 17 00:00:00 2001 From: MinhxNguyen7 Date: Thu, 7 Sep 2023 22:41:36 +0700 Subject: [PATCH 54/89] Update queryGrades documentation --- apps/antalmanac/src/lib/grades.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/antalmanac/src/lib/grades.ts b/apps/antalmanac/src/lib/grades.ts index d2561caaa..257cefc42 100644 --- a/apps/antalmanac/src/lib/grades.ts +++ b/apps/antalmanac/src/lib/grades.ts @@ -118,6 +118,7 @@ class _Grades { * @param deptCode The department code of the course. * @param courseNumber The course number of the course. * @param instructor The instructor's name (optional) + * @param cacheOnly Whether to only use the cache. If true, will return null if the query is not cached * * @returns Grades */ From d9143c0b8adc0f816316da6eed210947256c2756 Mon Sep 17 00:00:00 2001 From: MinhxNguyen7 Date: Thu, 7 Sep 2023 23:09:55 +0700 Subject: [PATCH 55/89] Fix grades query with GEs --- .../components/RightPane/CoursePane/CourseRenderPane.tsx | 4 ++-- apps/antalmanac/src/lib/grades.ts | 6 ++++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/antalmanac/src/components/RightPane/CoursePane/CourseRenderPane.tsx b/apps/antalmanac/src/components/RightPane/CoursePane/CourseRenderPane.tsx index ff5a61df8..b1906519a 100644 --- a/apps/antalmanac/src/components/RightPane/CoursePane/CourseRenderPane.tsx +++ b/apps/antalmanac/src/components/RightPane/CoursePane/CourseRenderPane.tsx @@ -7,7 +7,7 @@ import LazyLoad from 'react-lazyload'; import { Alert } from '@mui/material'; import { AACourse, AASection } from '@packages/antalmanac-types'; -import { WebsocDepartment, WebsocSchool, WebsocAPIResponse } from 'peterportal-api-next-types'; +import { WebsocDepartment, WebsocSchool, WebsocAPIResponse, GE } from 'peterportal-api-next-types'; import RightPaneStore from '../RightPaneStore'; import GeDataFetchProvider from '../SectionTable/GEDataFetchProvider'; import SectionTableLazyWrapper from '../SectionTable/SectionTableLazyWrapper'; @@ -233,7 +233,7 @@ class CourseRenderPane extends PureComponent => { + department = department != 'ALL' ? department : undefined; + if (!department && !ge) throw new Error('populategradesCache: Must provide either department or ge'); const queryKey = `${department ?? ''}${ge ?? ''}`; @@ -71,7 +73,7 @@ class _Grades { // If the whole query has already been cached, return if (this.cachedQueries.has(queryKey)) return; - const filter = `${ge ? `ge: ${ge} ` : ''}${department ? `department: "${department}" ` : ''}`; + const filter = `${ge ? `ge: ${ge.replace('-', '_')} ` : ''}${department ? `department: "${department}" ` : ''}`; const response = await queryGraphQL(`{ aggregateGroupedGrades(${filter}) { From 2ab1f9a9be7a1c6797feb87b16b8b862863aaa72 Mon Sep 17 00:00:00 2001 From: MinhxNguyen7 Date: Thu, 7 Sep 2023 23:11:25 +0700 Subject: [PATCH 56/89] Bugfix for reversion to old MUI --- .../src/components/RightPane/SectionTable/SectionTable.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTable.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTable.tsx index 78edf9d87..1d795d436 100644 --- a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTable.tsx +++ b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTable.tsx @@ -191,7 +191,12 @@ function SectionTable(props: SectionTableProps) { {tableHeaderColumnEntries .filter(([column]) => activeColumns.includes(column as SectionTableColumn)) .map(([column, { label, width }]) => ( - + {label === 'Enrollment' ? : label} ))} From 3617dffa6c01e0c1dd59d845c9a84c1429399c32 Mon Sep 17 00:00:00 2001 From: MinhxNguyen7 Date: Thu, 7 Sep 2023 23:24:10 +0700 Subject: [PATCH 57/89] Fix grades in added courses --- .../components/RightPane/SectionTable/SectionTableBody.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx index a6eecaba3..85cce80a0 100644 --- a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx +++ b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx @@ -206,7 +206,12 @@ async function getGpaData(deptCode: string, courseNumber: string, instructors: s // Get the GPA of the first instructor of this section where data exists for (const instructor of namedInstructors) { - const grades = await Grades.queryGrades(deptCode, courseNumber, instructor); + const grades = await Grades.queryGrades( + deptCode, + courseNumber, + instructor, + useTabStore.getState().activeTab != 1 + ); if (grades?.averageGPA) { return { gpa: grades.averageGPA.toFixed(2).toString(), From fc7761662caa3cf5bff6b6729e562b21f0ec56f0 Mon Sep 17 00:00:00 2001 From: MinhxNguyen7 Date: Fri, 8 Sep 2023 12:30:00 +0700 Subject: [PATCH 58/89] Correct documentation in grades popup --- .../src/components/RightPane/SectionTable/GradesPopup.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/GradesPopup.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/GradesPopup.tsx index 1b5acb428..f3efec5da 100644 --- a/apps/antalmanac/src/components/RightPane/SectionTable/GradesPopup.tsx +++ b/apps/antalmanac/src/components/RightPane/SectionTable/GradesPopup.tsx @@ -29,7 +29,7 @@ async function getGradeData( /** * Format data for displayiing in chart. * - * @example { sum_grade_a_count: 10, sum_grade_b_count: 20 } + * @example { gradeACount: 10, gradeBCount: 20 } */ const grades = Object.entries(courseGrades) .filter(([key]) => key !== 'averageGPA') From ccfb6e9c1a71ac65a9c981aac19dc9c7ed941c9f Mon Sep 17 00:00:00 2001 From: MinhxNguyen7 Date: Fri, 8 Sep 2023 12:32:07 +0700 Subject: [PATCH 59/89] Filter out "ANY" GE --- apps/antalmanac/src/lib/grades.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/antalmanac/src/lib/grades.ts b/apps/antalmanac/src/lib/grades.ts index d183e3f84..dbc8cce75 100644 --- a/apps/antalmanac/src/lib/grades.ts +++ b/apps/antalmanac/src/lib/grades.ts @@ -65,6 +65,7 @@ class _Grades { */ populateGradesCache = async ({ department, ge }: { department?: string; ge?: GE }): Promise => { department = department != 'ALL' ? department : undefined; + ge = ge != 'ANY' ? ge : undefined; if (!department && !ge) throw new Error('populategradesCache: Must provide either department or ge'); From 65c3238d3d1ed13dff9dcaf9a80c730133cd4bff Mon Sep 17 00:00:00 2001 From: MinhxNguyen7 Date: Fri, 8 Sep 2023 13:18:41 +0700 Subject: [PATCH 60/89] Change grades endpoint name --- apps/antalmanac/src/lib/grades.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/antalmanac/src/lib/grades.ts b/apps/antalmanac/src/lib/grades.ts index dbc8cce75..c4afa5cac 100644 --- a/apps/antalmanac/src/lib/grades.ts +++ b/apps/antalmanac/src/lib/grades.ts @@ -28,7 +28,7 @@ export interface GradesGraphQLResponse { export interface GroupedGradesGraphQLResponse { data: { - aggregateGroupedGrades: Array; + aggregateByOffering: Array; }; } @@ -77,7 +77,7 @@ class _Grades { const filter = `${ge ? `ge: ${ge.replace('-', '_')} ` : ''}${department ? `department: "${department}" ` : ''}`; const response = await queryGraphQL(`{ - aggregateGroupedGrades(${filter}) { + aggregateByOffering(${filter}) { department courseNumber instructor @@ -92,7 +92,7 @@ class _Grades { } }`); - const groupedGrades = response?.data?.aggregateGroupedGrades; + const groupedGrades = response?.data?.aggregateByOffering; if (!groupedGrades) throw new Error('populateGradesCache: Failed to query GraphQL'); From c41cdce6df5d0e3be78ce324c974303f0bf6f01e Mon Sep 17 00:00:00 2001 From: MinhxNguyen7 Date: Sat, 9 Sep 2023 11:53:01 +0700 Subject: [PATCH 61/89] TabStore default export --- apps/antalmanac/src/stores/TabStore.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/antalmanac/src/stores/TabStore.ts b/apps/antalmanac/src/stores/TabStore.ts index 61c756327..7f97837a4 100644 --- a/apps/antalmanac/src/stores/TabStore.ts +++ b/apps/antalmanac/src/stores/TabStore.ts @@ -18,3 +18,5 @@ export const useTabStore = create((set) => { }, }; }); + +export default useTabStore; From a683be5d3f54eb270f42c8ba11c4edbff9482ead Mon Sep 17 00:00:00 2001 From: MinhxNguyen7 Date: Sat, 9 Sep 2023 11:57:31 +0700 Subject: [PATCH 62/89] Remove remnants of tab store from RightPaneStore --- apps/antalmanac/src/components/RightPane/RightPaneStore.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/antalmanac/src/components/RightPane/RightPaneStore.ts b/apps/antalmanac/src/components/RightPane/RightPaneStore.ts index 75b9a0c8e..4a983fc99 100644 --- a/apps/antalmanac/src/components/RightPane/RightPaneStore.ts +++ b/apps/antalmanac/src/components/RightPane/RightPaneStore.ts @@ -48,7 +48,6 @@ export interface BuildingFocusInfo { class RightPaneStore extends EventEmitter { private formData: Record; - private activeTab: number; private doDisplaySearch: boolean; private openSpotAlertPopoverActive: boolean; private urlCourseCodeValue: string; @@ -67,7 +66,6 @@ class RightPaneStore extends EventEmitter { super(); this.setMaxListeners(15); this.formData = structuredClone(defaultFormValues); - this.activeTab = 0; this.doDisplaySearch = true; this.openSpotAlertPopoverActive = false; const search = new URLSearchParams(window.location.search); @@ -124,4 +122,4 @@ class RightPaneStore extends EventEmitter { } const store = new RightPaneStore(); -export default store; \ No newline at end of file +export default store; From 78d393dbd06ff1228e6f9487f8cc098e19debf93 Mon Sep 17 00:00:00 2001 From: MinhxNguyen7 Date: Sat, 9 Sep 2023 12:22:39 +0700 Subject: [PATCH 63/89] Migrate columns to separate store --- .../CoursePane/CoursePaneButtonRow.tsx | 26 ++++------- .../components/RightPane/RightPaneStore.ts | 33 -------------- .../RightPane/SectionTable/SectionTable.tsx | 16 +------ .../SectionTable/SectionTableBody.tsx | 19 +------- apps/antalmanac/src/stores/ColumnStore.ts | 44 +++++++++++++++++++ 5 files changed, 56 insertions(+), 82 deletions(-) create mode 100644 apps/antalmanac/src/stores/ColumnStore.ts diff --git a/apps/antalmanac/src/components/RightPane/CoursePane/CoursePaneButtonRow.tsx b/apps/antalmanac/src/components/RightPane/CoursePane/CoursePaneButtonRow.tsx index e717ada14..036385d31 100644 --- a/apps/antalmanac/src/components/RightPane/CoursePane/CoursePaneButtonRow.tsx +++ b/apps/antalmanac/src/components/RightPane/CoursePane/CoursePaneButtonRow.tsx @@ -12,7 +12,8 @@ import { type SxProps, } from '@mui/material'; import { ArrowBack, Visibility, Refresh } from '@mui/icons-material'; -import RightPaneStore, { type SectionTableColumn } from '../RightPaneStore'; +import RightPaneStore from '../RightPaneStore'; +import useColumnStore, { type SectionTableColumn } from '$stores/ColumnStore'; /** * All the interactive buttons have the same styles. @@ -59,7 +60,7 @@ function renderEmptySelectValue() { * e.g. show/hide the section code, instructors, etc. */ export function ColumnToggleButton() { - const [activeColumns, setActiveColumns] = useState(RightPaneStore.getActiveColumns()); + const { activeColumns, setActiveColumns } = useColumnStore(); const [open, setOpen] = useState(false); const handleColumnChange = useCallback( @@ -69,14 +70,11 @@ export function ColumnToggleButton() { [setActiveColumns] ); - const handleChange = useCallback( - (e: SelectChangeEvent) => { - if (typeof e.target.value !== 'string') { - RightPaneStore.setActiveColumns(e.target.value); - } - }, - [RightPaneStore.setActiveColumns] - ); + const handleChange = (e: SelectChangeEvent) => { + if (typeof e.target.value !== 'string') { + setActiveColumns(e.target.value); + } + }; const handleOpen = useCallback(() => { setOpen(true); @@ -86,14 +84,6 @@ export function ColumnToggleButton() { setOpen(false); }, [setOpen]); - useEffect(() => { - RightPaneStore.on('columnChange', handleColumnChange); - - return () => { - RightPaneStore.removeListener('columnChange', handleColumnChange); - }; - }, [handleColumnChange]); - return ( <> diff --git a/apps/antalmanac/src/components/RightPane/RightPaneStore.ts b/apps/antalmanac/src/components/RightPane/RightPaneStore.ts index 4a983fc99..6c01f1939 100644 --- a/apps/antalmanac/src/components/RightPane/RightPaneStore.ts +++ b/apps/antalmanac/src/components/RightPane/RightPaneStore.ts @@ -2,28 +2,6 @@ import { EventEmitter } from 'events'; import { getDefaultTerm } from '$lib/termData'; -/** - * Search results are displayed in a tabular format. - * - * Users can toggle certain columns on/off. - */ -export const SECTION_TABLE_COLUMNS = [ - // These two are omitted since they're not iterated over in the template. - // 'scheduleAdd', - // 'colorAndDelete', - 'sectionCode', - 'sectionDetails', - 'instructors', - 'gpa', - 'dayAndTime', - 'location', - 'sectionEnrollment', - 'restrictions', - 'status', -] as const; - -export type SectionTableColumn = (typeof SECTION_TABLE_COLUMNS)[number]; - const defaultFormValues: Record = { deptValue: 'ALL', deptLabel: 'ALL: Include All Departments', @@ -57,11 +35,6 @@ class RightPaneStore extends EventEmitter { private urlDeptLabel: string; private urlDeptValue: string; - /** - * The columns that are currently being displayed in the search results. - */ - private activeColumns: SectionTableColumn[] = [...SECTION_TABLE_COLUMNS]; - constructor() { super(); this.setMaxListeners(15); @@ -95,7 +68,6 @@ class RightPaneStore extends EventEmitter { getUrlCourseNumValue = () => this.urlCourseNumValue; getUrlDeptLabel = () => this.urlDeptLabel; getUrlDeptValue = () => this.urlDeptValue; - getActiveColumns = () => this.activeColumns; updateFormValue = (field: string, value: string) => { this.formData[field] = value; @@ -114,11 +86,6 @@ class RightPaneStore extends EventEmitter { toggleOpenSpotAlert = () => { this.openSpotAlertPopoverActive = !this.openSpotAlertPopoverActive; }; - - setActiveColumns = (newActiveColumns: SectionTableColumn[]) => { - this.activeColumns = newActiveColumns; - this.emit('columnChange', newActiveColumns); - }; } const store = new RightPaneStore(); diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTable.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTable.tsx index 1d795d436..105455d4c 100644 --- a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTable.tsx +++ b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTable.tsx @@ -14,12 +14,12 @@ import { } from '@material-ui/core'; import { Assessment, Help, RateReview, ShowChart as ShowChartIcon } from '@material-ui/icons'; import { MOBILE_BREAKPOINT } from '../../../globals'; -import RightPaneStore, { SECTION_TABLE_COLUMNS, type SectionTableColumn } from '../RightPaneStore'; import CourseInfoBar from './CourseInfoBar'; import CourseInfoButton from './CourseInfoButton'; import GradesPopup from './GradesPopup'; import { SectionTableProps } from './SectionTable.types'; import SectionTableBody from './SectionTableBody'; +import useColumnStore, { SECTION_TABLE_COLUMNS, type SectionTableColumn } from '$stores/ColumnStore'; import analyticsEnum from '$lib/analytics'; const TOTAL_NUM_COLUMNS = SECTION_TABLE_COLUMNS.length; @@ -104,7 +104,7 @@ function EnrollmentColumnHeader(props: EnrollmentColumnHeaderProps) { function SectionTable(props: SectionTableProps) { const { courseDetails, term, allowHighlight, scheduleNames, analyticsCategory } = props; - const [activeColumns, setActiveColumns] = useState(RightPaneStore.getActiveColumns()); + const { activeColumns } = useColumnStore(); const isMobileScreen = useMediaQuery(`(max-width: ${MOBILE_BREAKPOINT}`); @@ -125,18 +125,6 @@ function SectionTable(props: SectionTableProps) { return (width * numActiveColumns) / TOTAL_NUM_COLUMNS; }, [isMobileScreen, activeColumns]); - useEffect(() => { - const handleColumnChange = (newActiveColumns: SectionTableColumn[]) => { - setActiveColumns(newActiveColumns); - }; - - RightPaneStore.on('columnChange', handleColumnChange); - - return () => { - RightPaneStore.removeListener('columnChange', handleColumnChange); - }; - }, []); - return ( <> diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx index 85cce80a0..50576acc4 100644 --- a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx +++ b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx @@ -20,7 +20,6 @@ import { Fragment, useCallback, useContext, useEffect, useMemo, useState } from import { AASection } from '@packages/antalmanac-types'; import { WebsocSectionEnrollment, WebsocSectionMeeting } from 'peterportal-api-next-types'; -import RightPaneStore, { type SectionTableColumn } from '../RightPaneStore'; import { MOBILE_BREAKPOINT } from '../../../globals'; import { OpenSpotAlertPopoverProps } from './OpenSpotAlertPopover'; import { ColorAndDelete, ScheduleAddCell } from './SectionTableButtons'; @@ -31,9 +30,9 @@ import { clickToCopy, CourseDetails, isDarkMode } from '$lib/helpers'; import Grades from '$lib/grades'; import AppStore from '$stores/AppStore'; import { useTabStore } from '$stores/TabStore'; -import { mobileContext } from '$components/MobileHome'; import locationIds from '$lib/location_ids'; import { normalizeTime, parseDaysString, translate24To12HourTime } from '$stores/calendarizeHelpers'; +import useColumnStore, { type SectionTableColumn } from '$stores/ColumnStore'; const styles: Styles = (theme) => ({ sectionCode: { @@ -497,7 +496,7 @@ const tableBodyCells: Record> = { const SectionTableBody = withStyles(styles)((props: SectionTableBodyProps) => { const { classes, section, courseDetails, term, allowHighlight, scheduleNames } = props; - const [activeColumns, setColumns] = useState(RightPaneStore.getActiveColumns()); + const { activeColumns } = useColumnStore(); const [addedCourse, setAddedCourse] = useState( AppStore.getAddedSectionCodes().has(`${section.sectionCode} ${term}`) @@ -518,13 +517,6 @@ const SectionTableBody = withStyles(styles)((props: SectionTableBodyProps) => { // Stable references to event listeners will synchronize React state with the store. - const updateColumns = useCallback( - (newActiveColumns: SectionTableColumn[]) => { - setColumns(newActiveColumns); - }, - [setColumns] - ); - const updateHighlight = useCallback(() => { setAddedCourse(AppStore.getAddedSectionCodes().has(`${section.sectionCode} ${term}`)); }, [setAddedCourse]); @@ -535,13 +527,6 @@ const SectionTableBody = withStyles(styles)((props: SectionTableBodyProps) => { // Attach event listeners to the store. - useEffect(() => { - RightPaneStore.on('columnChange', updateColumns); - return () => { - RightPaneStore.removeListener('columnChange', updateColumns); - }; - }, [updateColumns]); - useEffect(() => { AppStore.on('addedCoursesChange', updateHighlight); AppStore.on('currentScheduleIndexChange', updateHighlight); diff --git a/apps/antalmanac/src/stores/ColumnStore.ts b/apps/antalmanac/src/stores/ColumnStore.ts new file mode 100644 index 000000000..4489eb2c3 --- /dev/null +++ b/apps/antalmanac/src/stores/ColumnStore.ts @@ -0,0 +1,44 @@ +import { create } from 'zustand'; + +/** + * Search results are displayed in a tabular format. + * + * Users can toggle certain columns on/off. + */ +export const SECTION_TABLE_COLUMNS = [ + // These two are omitted since they're not iterated over in the template. + // 'scheduleAdd', + // 'colorAndDelete', + 'sectionCode', + 'sectionDetails', + 'instructors', + 'gpa', + 'dayAndTime', + 'location', + 'sectionEnrollment', + 'restrictions', + 'status', +] as const; + +export type SectionTableColumn = (typeof SECTION_TABLE_COLUMNS)[number]; + +interface ColumnStore { + activeColumns: SectionTableColumn[]; + setActiveColumns: (columns: SectionTableColumn[]) => void; +} + +/** + * Store of columns that are currently being displayed in the search results. + */ +export const useColumnStore = create((set) => { + return { + activeColumns: [...SECTION_TABLE_COLUMNS], + setActiveColumns: (columns: SectionTableColumn[]) => { + set(() => ({ + activeColumns: columns, + })); + }, + }; +}); + +export default useColumnStore; From d0a59a77dfd04eb50449ad4775c9217899df2bba Mon Sep 17 00:00:00 2001 From: MinhxNguyen7 Date: Sat, 9 Sep 2023 12:45:30 +0700 Subject: [PATCH 64/89] Remove remnant of old column store --- .../RightPane/CoursePane/CoursePaneButtonRow.tsx | 7 ------- 1 file changed, 7 deletions(-) diff --git a/apps/antalmanac/src/components/RightPane/CoursePane/CoursePaneButtonRow.tsx b/apps/antalmanac/src/components/RightPane/CoursePane/CoursePaneButtonRow.tsx index 036385d31..af80cd439 100644 --- a/apps/antalmanac/src/components/RightPane/CoursePane/CoursePaneButtonRow.tsx +++ b/apps/antalmanac/src/components/RightPane/CoursePane/CoursePaneButtonRow.tsx @@ -63,13 +63,6 @@ export function ColumnToggleButton() { const { activeColumns, setActiveColumns } = useColumnStore(); const [open, setOpen] = useState(false); - const handleColumnChange = useCallback( - (newActiveColumns: SectionTableColumn[]) => { - setActiveColumns(newActiveColumns); - }, - [setActiveColumns] - ); - const handleChange = (e: SelectChangeEvent) => { if (typeof e.target.value !== 'string') { setActiveColumns(e.target.value); From 190fe466f81feb6ba046884226e23c130e3e8bd2 Mon Sep 17 00:00:00 2001 From: MinhxNguyen7 Date: Sat, 9 Sep 2023 13:26:26 +0700 Subject: [PATCH 65/89] Enable/disable columns independantly from selected --- .../CoursePane/CoursePaneButtonRow.tsx | 8 +-- .../RightPane/SectionTable/SectionTable.tsx | 8 +-- .../SectionTable/SectionTableBody.tsx | 4 +- apps/antalmanac/src/stores/ColumnStore.ts | 51 ++++++++++++++++--- 4 files changed, 54 insertions(+), 17 deletions(-) diff --git a/apps/antalmanac/src/components/RightPane/CoursePane/CoursePaneButtonRow.tsx b/apps/antalmanac/src/components/RightPane/CoursePane/CoursePaneButtonRow.tsx index af80cd439..dada1c5ef 100644 --- a/apps/antalmanac/src/components/RightPane/CoursePane/CoursePaneButtonRow.tsx +++ b/apps/antalmanac/src/components/RightPane/CoursePane/CoursePaneButtonRow.tsx @@ -60,12 +60,12 @@ function renderEmptySelectValue() { * e.g. show/hide the section code, instructors, etc. */ export function ColumnToggleButton() { - const { activeColumns, setActiveColumns } = useColumnStore(); + const { getActiveColumns, setSelectedColumns } = useColumnStore(); const [open, setOpen] = useState(false); const handleChange = (e: SelectChangeEvent) => { if (typeof e.target.value !== 'string') { - setActiveColumns(e.target.value); + setSelectedColumns(e.target.value); } }; @@ -87,7 +87,7 @@ export function ColumnToggleButton() { - {Object.entries(columnLabels).map(([column, label]) => ( + {Object.entries(columnLabels).map(([column, label], idx) => ( - -1} - color="default" - /> + ))} diff --git a/apps/antalmanac/src/lib/grades.ts b/apps/antalmanac/src/lib/grades.ts index c4afa5cac..46e9f2254 100644 --- a/apps/antalmanac/src/lib/grades.ts +++ b/apps/antalmanac/src/lib/grades.ts @@ -35,6 +35,8 @@ export interface GroupedGradesGraphQLResponse { /** * Class to handle querying and caching of grades. * Retrieves grades from the PeterPortal GraphQL API. + * + * Note: Be careful with sending too many queries to the GraphQL API. It's not very fast and can be DoS'd easily. */ class _Grades { gradesCache: Record; diff --git a/apps/antalmanac/src/stores/ColumnStore.ts b/apps/antalmanac/src/stores/ColumnStore.ts index fa110dc88..0e11a2ae7 100644 --- a/apps/antalmanac/src/stores/ColumnStore.ts +++ b/apps/antalmanac/src/stores/ColumnStore.ts @@ -69,10 +69,8 @@ export const useColumnStore = create((set, get) => { }, setColumnEnabled: (column: SectionTableColumn, state: boolean) => { set((prevState) => { - const enabledColumns = prevState.enabledColumns.filter((current, index) => - SECTION_TABLE_COLUMNS[index] === column ? state : current - ); - return { enabledColumns: enabledColumns }; + prevState.enabledColumns[SECTION_TABLE_COLUMNS.indexOf(column)] = state; + return { enabledColumns: prevState.enabledColumns }; }); }, }; diff --git a/apps/antalmanac/src/stores/TabStore.ts b/apps/antalmanac/src/stores/TabStore.ts index 7f97837a4..298d562aa 100644 --- a/apps/antalmanac/src/stores/TabStore.ts +++ b/apps/antalmanac/src/stores/TabStore.ts @@ -1,4 +1,5 @@ import { create } from 'zustand'; +import useColumnStore from './ColumnStore'; interface TabStore { activeTab: number; @@ -15,6 +16,13 @@ export const useTabStore = create((set) => { set(() => ({ activeTab: newTab, })); + // Disable GPA column on the Added tab because we'd have to query them individually + // A column needs to be enabled and selected to be displayed + if (newTab == 1) { + useColumnStore.getState().setColumnEnabled('gpa', false); + } else { + useColumnStore.getState().setColumnEnabled('gpa', true); + } }, }; }); From 073a44947d10e43d95664146bf04a8e820914b2b Mon Sep 17 00:00:00 2001 From: MinhxNguyen7 <64875104+MinhxNguyen7@users.noreply.github.com> Date: Sat, 16 Sep 2023 00:30:21 +0000 Subject: [PATCH 67/89] Remove redundant import --- apps/antalmanac/src/lib/grades.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/antalmanac/src/lib/grades.ts b/apps/antalmanac/src/lib/grades.ts index 46e9f2254..8e0d5d8e9 100644 --- a/apps/antalmanac/src/lib/grades.ts +++ b/apps/antalmanac/src/lib/grades.ts @@ -1,4 +1,4 @@ -import { GE, geCodes } from 'peterportal-api-next-types'; +import { GE } from 'peterportal-api-next-types'; import { queryGraphQL } from './helpers'; export interface Grades { From 2bd297ba5ed1868aaea47ec62588aedd712e9bca Mon Sep 17 00:00:00 2001 From: MinhxNguyen7 <64875104+MinhxNguyen7@users.noreply.github.com> Date: Sat, 16 Sep 2023 01:01:33 +0000 Subject: [PATCH 68/89] Apply suggestions from review --- .../CoursePane/CoursePaneButtonRow.tsx | 19 ++++++++++++------- .../RightPane/SectionTable/GradesPopup.tsx | 2 +- .../RightPane/SectionTable/SectionTable.tsx | 2 +- .../SectionTable/SectionTableBody.tsx | 4 ++-- 4 files changed, 16 insertions(+), 11 deletions(-) diff --git a/apps/antalmanac/src/components/RightPane/CoursePane/CoursePaneButtonRow.tsx b/apps/antalmanac/src/components/RightPane/CoursePane/CoursePaneButtonRow.tsx index 67cfd62e0..7a5db1a8d 100644 --- a/apps/antalmanac/src/components/RightPane/CoursePane/CoursePaneButtonRow.tsx +++ b/apps/antalmanac/src/components/RightPane/CoursePane/CoursePaneButtonRow.tsx @@ -12,8 +12,7 @@ import { type SxProps, } from '@mui/material'; import { ArrowBack, Visibility, Refresh } from '@mui/icons-material'; -import RightPaneStore from '../RightPaneStore'; -import useColumnStore, { SECTION_TABLE_COLUMNS, type SectionTableColumn } from '$stores/ColumnStore'; +import { useColumnStore, SECTION_TABLE_COLUMNS, type SectionTableColumn } from '$stores/ColumnStore'; /** * All the interactive buttons have the same styles. @@ -60,7 +59,7 @@ function renderEmptySelectValue() { * e.g. show/hide the section code, instructors, etc. */ export function ColumnToggleButton() { - const { selectedColumns, setSelectedColumns } = useColumnStore(); + const [ selectedColumns, setSelectedColumns ] = useColumnStore(store => [store.selectedColumns, store.setSelectedColumns]); const [open, setOpen] = useState(false); const handleChange = (e: SelectChangeEvent) => { @@ -75,13 +74,19 @@ export function ColumnToggleButton() { const handleClose = useCallback(() => { setOpen(false); - }, [setOpen]); + }, []); const selectedColumnNames = useMemo( - () => SECTION_TABLE_COLUMNS.filter((_, idx) => selectedColumns[idx]), + () => SECTION_TABLE_COLUMNS.filter((_, index) => selectedColumns[index]), [selectedColumns] ); + const columnLabelEntries = useMemo( + () => Object.entries(columnLabels), + [] + ); + + return ( <> @@ -97,9 +102,9 @@ export function ColumnToggleButton() { onChange={handleChange} onClose={handleClose} renderValue={renderEmptySelectValue} - sx={{ visibility: 'hidden' }} + sx={{ visibility: 'hidden', position: 'absolute' }} > - {Object.entries(columnLabels).map(([column, label], idx) => ( + {columnLabelEntries.map(([column, label], idx) => ( diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/GradesPopup.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/GradesPopup.tsx index f3efec5da..c26da1304 100644 --- a/apps/antalmanac/src/components/RightPane/SectionTable/GradesPopup.tsx +++ b/apps/antalmanac/src/components/RightPane/SectionTable/GradesPopup.tsx @@ -27,7 +27,7 @@ async function getGradeData( } /** - * Format data for displayiing in chart. + * Format data for displaying in chart. * * @example { gradeACount: 10, gradeBCount: 20 } */ diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTable.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTable.tsx index f6067660d..b7b73b175 100644 --- a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTable.tsx +++ b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTable.tsx @@ -77,7 +77,7 @@ interface EnrollmentColumnHeaderProps { } function EnrollmentColumnHeader(props: EnrollmentColumnHeaderProps) { - const isMobileScreen = useMediaQuery(`(max-width: ${MOBILE_BREAKPOINT}`); + const isMobileScreen = useMediaQuery(`(max-width: ${MOBILE_BREAKPOINT})`); return ( diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx index 3e309ce93..8e3976a87 100644 --- a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx +++ b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx @@ -496,7 +496,7 @@ const tableBodyCells: Record> = { const SectionTableBody = withStyles(styles)((props: SectionTableBodyProps) => { const { classes, section, courseDetails, term, allowHighlight, scheduleNames } = props; - const { getActiveColumns } = useColumnStore(); + const getActiveColumns = useColumnStore(store => store.getActiveColumns); const [addedCourse, setAddedCourse] = useState( AppStore.getAddedSectionCodes().has(`${section.sectionCode} ${term}`) @@ -519,7 +519,7 @@ const SectionTableBody = withStyles(styles)((props: SectionTableBodyProps) => { const updateHighlight = useCallback(() => { setAddedCourse(AppStore.getAddedSectionCodes().has(`${section.sectionCode} ${term}`)); - }, [setAddedCourse]); + }, []); const updateCalendarEvents = useCallback(() => { setCalendarEvents(AppStore.getCourseEventsInCalendar()); From d772d23a801f31533488cd001e3e907a811f1a58 Mon Sep 17 00:00:00 2001 From: MinhxNguyen7 <64875104+MinhxNguyen7@users.noreply.github.com> Date: Sat, 16 Sep 2023 01:11:22 +0000 Subject: [PATCH 69/89] Add missing parenthesis --- .../src/components/RightPane/SectionTable/SectionTable.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTable.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTable.tsx index b7b73b175..939f39944 100644 --- a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTable.tsx +++ b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTable.tsx @@ -106,7 +106,7 @@ function SectionTable(props: SectionTableProps) { const { selectedColumns, getActiveColumns } = useColumnStore(); - const isMobileScreen = useMediaQuery(`(max-width: ${MOBILE_BREAKPOINT}`); + const isMobileScreen = useMediaQuery(`(max-width: ${MOBILE_BREAKPOINT})`); const courseId = useMemo(() => { return courseDetails.deptCode.replaceAll(' ', '') + courseDetails.courseNumber; From 0f70d1ea2f673ddfed55eac213d6f52451615590 Mon Sep 17 00:00:00 2001 From: MinhxNguyen7 <64875104+MinhxNguyen7@users.noreply.github.com> Date: Sat, 16 Sep 2023 01:36:16 +0000 Subject: [PATCH 70/89] Add course and instructor to graph title --- .../components/RightPane/SectionTable/GradesPopup.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/GradesPopup.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/GradesPopup.tsx index c26da1304..16616d16c 100644 --- a/apps/antalmanac/src/components/RightPane/SectionTable/GradesPopup.tsx +++ b/apps/antalmanac/src/components/RightPane/SectionTable/GradesPopup.tsx @@ -63,9 +63,14 @@ function GradesPopup(props: GradesPopupProps) { const graphTitle = useMemo(() => { return gradeData - ? `Grade Distribution | Average GPA: ${gradeData.courseGrades.averageGPA.toFixed(2)}` + ? `${deptCode} ${courseNumber}${instructor ? ` — ${instructor}` : "" } | Average GPA: ${gradeData.courseGrades.averageGPA.toFixed(2)}` : 'Grades are not available for this class.'; - }, [gradeData]); + }, [deptCode, instructor, gradeData]); + + const gpaString = useMemo( + () => (gradeData ? `Average GPA: ${gradeData.courseGrades.averageGPA.toFixed(2)}` : ""), + [gradeData] + ) useEffect(() => { if (loading === false) { From 9487d02dd3705e296268f35b1a193482c43caf4b Mon Sep 17 00:00:00 2001 From: MinhxNguyen7 <64875104+MinhxNguyen7@users.noreply.github.com> Date: Sat, 16 Sep 2023 02:03:51 +0000 Subject: [PATCH 71/89] Fix styling inconsistency --- .../components/RightPane/SectionTable/SectionTableBody.tsx | 4 ---- 1 file changed, 4 deletions(-) diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx index 8e3976a87..1adfbdebf 100644 --- a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx +++ b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx @@ -273,10 +273,6 @@ function GPACell(props: GPACellProps) { Date: Sat, 16 Sep 2023 18:03:25 +0000 Subject: [PATCH 72/89] Tweak section table columns spacings --- .../src/components/RightPane/SectionTable/SectionTable.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTable.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTable.tsx index 939f39944..5017c1659 100644 --- a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTable.tsx +++ b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTable.tsx @@ -43,11 +43,11 @@ const tableHeaderColumns: Record = }, instructors: { label: 'Instructors', - width: '15%', + width: '13%', }, gpa: { label: 'GPA', - width: '7%', + width: '6%', }, dayAndTime: { label: 'Times', @@ -55,7 +55,7 @@ const tableHeaderColumns: Record = }, location: { label: 'Places', - width: '10%', + width: '8%', }, sectionEnrollment: { label: 'Enrollment', From ba6af16425569e0656d96168ef1ce7868e3b09f9 Mon Sep 17 00:00:00 2001 From: Aponia Date: Fri, 15 Sep 2023 17:41:01 -0700 Subject: [PATCH 73/89] feat: multiple locations on courses (#696) Co-authored-by: Eric Pedley Co-authored-by: alanchangxyz --- .../src/components/Calendar/CalendarRoot.tsx | 38 +++++++------ .../Calendar/CourseCalendarEvent.tsx | 56 +++++++++++++------ apps/antalmanac/src/components/Map/Map.tsx | 43 ++++++++------ .../SectionTable/SectionTableBody.tsx | 28 ++++++---- apps/antalmanac/src/lib/download.ts | 2 +- .../src/stores/calendarizeHelpers.ts | 55 +++++++++++------- .../tests/calendarize-helpers.test.ts | 27 +++++---- 7 files changed, 154 insertions(+), 95 deletions(-) diff --git a/apps/antalmanac/src/components/Calendar/CalendarRoot.tsx b/apps/antalmanac/src/components/Calendar/CalendarRoot.tsx index 085974860..7d763dabf 100644 --- a/apps/antalmanac/src/components/Calendar/CalendarRoot.tsx +++ b/apps/antalmanac/src/components/Calendar/CalendarRoot.tsx @@ -69,26 +69,28 @@ const AntAlmanacEvent = ({ classes }: { classes: ClassNameMap }) => // eslint-disable-next-line react/display-name ({ event }: { event: CalendarEvent }) => { - if (!event.isCustomEvent) - return ( -
-
-
{event.title}
-
{event.sectionType}
-
-
-
{event.bldg}
-
{event.sectionCode}
-
+ return event.isCustomEvent ? ( +
+
{event.title}
+
+ ) : ( +
+
+
{event.title}
+
{event.sectionType}
- ); - else { - return ( -
-
{event.title}
+
+
+ {event.showLocationInfo + ? event.locations.map((location) => `${location.building} ${location.room}`).join(', ') + : event.locations.length > 1 + ? `${event.locations.length} Locations` + : `${event.locations[0].building} ${event.locations[0].room}`} +
+
{event.sectionCode}
- ); - } +
+ ); }; interface ScheduleCalendarProps { classes: ClassNameMap; diff --git a/apps/antalmanac/src/components/Calendar/CourseCalendarEvent.tsx b/apps/antalmanac/src/components/Calendar/CourseCalendarEvent.tsx index b0adad7ff..105e434fc 100644 --- a/apps/antalmanac/src/components/Calendar/CourseCalendarEvent.tsx +++ b/apps/antalmanac/src/components/Calendar/CourseCalendarEvent.tsx @@ -88,8 +88,26 @@ interface CommonCalendarEvent extends Event { title: string; } +export interface Location { + /** + * @example 'ICS' + */ + building: string; + + /** + * @example '174' + */ + room: string; + + /** + * If the location only applies on specific days, this is non-null. + */ + days?: string[]; +} + export interface CourseEvent extends CommonCalendarEvent { - bldg: string; // E.g., ICS 174, which is actually building + room + locations: Location[]; + showLocationInfo: boolean; finalExam: { examStatus: 'NO_FINAL' | 'TBA_FINAL' | 'SCHEDULED_FINAL'; dayOfWeek: 'Sun' | 'Mon' | 'Tue' | 'Wed' | 'Thu' | 'Fri' | 'Sat' | null; @@ -103,7 +121,7 @@ export interface CourseEvent extends CommonCalendarEvent { hour: number; minute: number; } | null; - bldg: string[] | null; + locations: Location[] | null; }; courseTitle: string; instructors: string[]; @@ -158,22 +176,22 @@ const CourseCalendarEvent = (props: CourseCalendarEventProps) => { }, [setActiveTab]); const { classes, courseInMoreInfo } = props; - if (!courseInMoreInfo.isCustomEvent) { - const { term, instructors, sectionCode, title, finalExam, bldg, sectionType } = courseInMoreInfo; - - const [buildingName = ''] = bldg.split(' '); - const buildingId = locationIds[buildingName] ?? 69420; + if (!courseInMoreInfo.isCustomEvent) { + const { term, instructors, sectionCode, title, finalExam, locations, sectionType } = courseInMoreInfo; let finalExamString = ''; + if (finalExam.examStatus == 'NO_FINAL') { finalExamString = 'No Final'; } else if (finalExam.examStatus == 'TBA_FINAL') { finalExamString = 'Final TBA'; } else { - if (finalExam.startTime && finalExam.endTime && finalExam.month && finalExam.bldg) { + if (finalExam.startTime && finalExam.endTime && finalExam.month && finalExam.locations) { const timeString = translate24To12HourTime(finalExam.startTime, finalExam.endTime); - const locationString = `at ${finalExam.bldg.join(', ')}`; + const locationString = `at ${finalExam.locations + .map((location) => `${location.building} ${location.room}`) + .join(', ')}`; const finalExamMonth = MONTHS[finalExam.month]; finalExamString = `${finalExam.dayOfWeek} ${finalExamMonth} ${finalExam.day} ${timeString} ${locationString}`; @@ -229,15 +247,19 @@ const CourseCalendarEvent = (props: CourseCalendarEventProps) => {
- + diff --git a/apps/antalmanac/src/components/Map/Map.tsx b/apps/antalmanac/src/components/Map/Map.tsx index d19a00427..242f478bb 100644 --- a/apps/antalmanac/src/components/Map/Map.tsx +++ b/apps/antalmanac/src/components/Map/Map.tsx @@ -41,7 +41,7 @@ interface MarkerContent { export function getCoursesPerBuilding() { const courseEvents = AppStore.getCourseEventsInCalendar(); - const allBuildingCodes = courseEvents.map((event) => event.bldg.split(' ').slice(0, -1).join(' ')); + const allBuildingCodes = courseEvents.flatMap((event) => event.locations.map((location) => location.building)); const uniqueBuildingCodes = new Set(allBuildingCodes); @@ -53,10 +53,10 @@ export function getCoursesPerBuilding() { validBuildingCodes.forEach((buildingCode) => { coursesPerBuilding[buildingCode] = courseEvents - .filter((event) => event.bldg.split(' ').slice(0, -1).join(' ') === buildingCode) + .filter((event) => event.locations.map((location) => location.building).includes(buildingCode)) .map((event) => { const locationData = buildingCatalogue[locationIds[buildingCode]]; - const key = `${event.title} ${event.sectionType} @ ${event.bldg}`; + const key = `${event.title} ${event.sectionType} @ ${event.locations[0]}`; const acronym = locationData.name.substring( locationData.name.indexOf('(') + 1, locationData.name.indexOf(')') @@ -73,6 +73,7 @@ export function getCoursesPerBuilding() { return markerData; }); }); + return coursesPerBuilding; } @@ -97,15 +98,11 @@ export default function CourseMap() { const [markers, setMarkers] = useState(getCoursesPerBuilding()); const [calendarEvents, setCalendarEvents] = useState(AppStore.getCourseEventsInCalendar()); - const updateMarkers = useCallback(() => { - setMarkers(getCoursesPerBuilding()); - }, [setMarkers, getCoursesPerBuilding]); - - const updateCalendarEvents = useCallback(() => { - setCalendarEvents(AppStore.getCourseEventsInCalendar()); - }, [setCalendarEvents]); - useEffect(() => { + const updateMarkers = () => { + setMarkers(getCoursesPerBuilding()); + }; + AppStore.on('addedCoursesChange', updateMarkers); AppStore.on('currentScheduleIndexChange', updateMarkers); @@ -113,9 +110,13 @@ export default function CourseMap() { AppStore.removeListener('addedCoursesChange', updateMarkers); AppStore.removeListener('currentScheduleIndexChange', updateMarkers); }; - }, [updateMarkers]); + }, []); useEffect(() => { + const updateCalendarEvents = () => { + setCalendarEvents(AppStore.getCourseEventsInCalendar()); + }; + AppStore.on('addedCoursesChange', updateCalendarEvents); AppStore.on('currentScheduleIndexChange', updateCalendarEvents); @@ -123,7 +124,7 @@ export default function CourseMap() { AppStore.removeListener('addedCoursesChange', updateCalendarEvents); AppStore.removeListener('currentScheduleIndexChange', updateCalendarEvents); }; - }, [updateCalendarEvents]); + }, []); useEffect(() => { const locationID = Number(searchParams.get('location') ?? 0); @@ -215,7 +216,7 @@ export default function CourseMap() { return ( - {/** Menu floats above the map. */} + {/* Menu floats above the map. */} {days.map((day) => ( @@ -249,9 +250,16 @@ export default function CourseMap() { {/* Draw a marker for each class that occurs today. */} {markersToDisplay.map((marker, index) => { // Find all courses that occur in the same building prior to this one to stack them properly. + // TODO Handle multiple buildings between class comparisons on markers. const coursesSameBuildingPrior = markersToDisplay .slice(0, index) - .filter((m) => m.bldg.split(' ')[0] === marker.bldg.split(' ')[0]); + .filter((m) => + m.locations.map((location) => location.building).includes(marker.locations[0].building) + ); + + const allRoomsInBuilding = marker.locations + .filter((location) => location.building == marker.locations[0].building) + .reduce((roomList, location) => [...roomList, location.room], [] as string[]); return ( @@ -264,7 +272,10 @@ export default function CourseMap() { Class: {marker.title} {marker.sectionType} - Room: {marker.bldg.split(' ').slice(-1)} + + Room{allRoomsInBuilding.length > 1 && 's'}: {marker.locations[0].building}{' '} + {allRoomsInBuilding.join('/')} + diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx index 1adfbdebf..4720befd5 100644 --- a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx +++ b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx @@ -308,19 +308,23 @@ const LocationsCell = withStyles(styles)((props: LocationsCellProps) => { return ( {meetings.map((meeting) => { - const [buildingName = ''] = meeting.bldg[0].split(' '); - const buildingId = locationIds[buildingName]; return meeting.bldg[0] !== 'TBA' ? ( - - - {meeting.bldg} - -
-
+ meeting.bldg.map((bldg) => { + const [buildingName = ''] = bldg.split(' '); + const buildingId = locationIds[buildingName]; + return ( + + + {bldg} + +
+
+ ); + }) ) : ( {meeting.bldg} ); diff --git a/apps/antalmanac/src/lib/download.ts b/apps/antalmanac/src/lib/download.ts index e1447895f..8d0fe89b9 100644 --- a/apps/antalmanac/src/lib/download.ts +++ b/apps/antalmanac/src/lib/download.ts @@ -285,7 +285,7 @@ export function getEventsFromCourses(courses = AppStore.schedule.getCurrentCours endOutputType: 'local' as const, title: `${deptCode} ${courseNumber} ${sectionType}`, description: `${courseTitle}\nTaught by ${instructors.join('/')}`, - location: `${meeting.bldg}`, + location: meeting.bldg.join(', '), start: firstClassStart, end: firstClassEnd, recurrenceRule: rrule, diff --git a/apps/antalmanac/src/stores/calendarizeHelpers.ts b/apps/antalmanac/src/stores/calendarizeHelpers.ts index 0451af34e..1541cea7a 100644 --- a/apps/antalmanac/src/stores/calendarizeHelpers.ts +++ b/apps/antalmanac/src/stores/calendarizeHelpers.ts @@ -1,6 +1,6 @@ import { ScheduleCourse } from '@packages/antalmanac-types'; import { HourMinute } from 'peterportal-api-next-types'; -import { CourseEvent, CustomEvent } from '$components/Calendar/CourseCalendarEvent'; +import { CourseEvent, CustomEvent, Location } from '$components/Calendar/CourseCalendarEvent'; import { RepeatingCustomEvent } from '$components/Calendar/Toolbar/CustomEventDialog/CustomEventDialog'; import { notNull, getReferencesOccurring } from '$lib/utils'; @@ -8,6 +8,11 @@ const COURSE_WEEK_DAYS = ['Su', 'M', 'Tu', 'W', 'Th', 'F', 'Sa']; const FINALS_WEEK_DAYS = ['Sat', 'Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri']; +export function getLocation(location: string): Location { + const [building = '', room = ''] = location.split(' '); + return { building, room }; +} + export function calendarizeCourseEvents(currentCourses: ScheduleCourse[] = []): CourseEvent[] { return currentCourses.flatMap((course) => { return course.section.meetings @@ -34,19 +39,26 @@ export function calendarizeCourseEvents(currentCourses: ScheduleCourse[] = []): .map((day, index) => (day ? index : undefined)) .filter(notNull); + // Intermediate formatting to subtract `bldg` attribute in favor of `locations` + const { bldg: _, ...finalExam } = course.section.finalExam; + return dayIndicesOccurring.map((dayIndex) => { return { color: course.section.color, term: course.term, title: `${course.deptCode} ${course.courseNumber}`, courseTitle: course.courseTitle, - bldg: meeting.bldg[0], + locations: meeting.bldg.map(getLocation), + showLocationInfo: false, instructors: course.section.instructors, sectionCode: course.section.sectionCode, sectionType: course.section.sectionType, start: new Date(2018, 0, dayIndex, startHour, startMin), end: new Date(2018, 0, dayIndex, endHour, endMin), - finalExam: course.section.finalExam, + finalExam: { + ...finalExam, + locations: course.section.finalExam.bldg?.map(getLocation) ?? [], + }, isCustomEvent: false, }; }); @@ -64,7 +76,8 @@ export function calendarizeFinals(currentCourses: ScheduleCourse[] = []): Course course.section.finalExam.dayOfWeek ) .flatMap((course) => { - const finalExam = course.section.finalExam; + const { bldg, ...finalExam } = course.section.finalExam; + const startHour = finalExam.startTime?.hour; const startMin = finalExam.startTime?.minute; const endHour = finalExam.endTime?.hour; @@ -84,22 +97,24 @@ export function calendarizeFinals(currentCourses: ScheduleCourse[] = []): Course */ const dayIndicesOcurring = weekdaysOccurring.map((day, index) => (day ? index : undefined)).filter(notNull); - return dayIndicesOcurring.map((dayIndex) => { - return { - color: course.section.color, - term: course.term, - title: `${course.deptCode} ${course.courseNumber}`, - courseTitle: course.courseTitle, - bldg: course.section.meetings[0].bldg[0], - instructors: course.section.instructors, - sectionCode: course.section.sectionCode, - sectionType: 'Fin', - start: new Date(2018, 0, dayIndex - 1, startHour, startMin), - end: new Date(2018, 0, dayIndex - 1, endHour, endMin), - finalExam: course.section.finalExam, - isCustomEvent: false, - }; - }); + return dayIndicesOcurring.map((dayIndex) => ({ + color: course.section.color, + term: course.term, + title: `${course.deptCode} ${course.courseNumber}`, + courseTitle: course.courseTitle, + locations: bldg ? bldg.map(getLocation) : course.section.meetings[0].bldg.map(getLocation), + showLocationInfo: true, + instructors: course.section.instructors, + sectionCode: course.section.sectionCode, + sectionType: 'Fin', + start: new Date(2018, 0, dayIndex - 1, startHour, startMin), + end: new Date(2018, 0, dayIndex - 1, endHour, endMin), + finalExam: { + ...finalExam, + locations: bldg?.map(getLocation) ?? [], + }, + isCustomEvent: false, + })); }); } diff --git a/apps/antalmanac/tests/calendarize-helpers.test.ts b/apps/antalmanac/tests/calendarize-helpers.test.ts index 5b175f9bf..7c3bb0c2d 100644 --- a/apps/antalmanac/tests/calendarize-helpers.test.ts +++ b/apps/antalmanac/tests/calendarize-helpers.test.ts @@ -2,6 +2,7 @@ import { describe, test, expect } from 'vitest'; import type { Schedule } from '@packages/antalmanac-types'; import type { RepeatingCustomEvent } from '$components/Calendar/Toolbar/CustomEventDialog/CustomEventDialog'; import { calendarizeCourseEvents, calendarizeCustomEvents, calendarizeFinals } from '$stores/calendarizeHelpers'; +import type { CourseEvent, CustomEvent } from '$components/Calendar/CourseCalendarEvent'; describe('calendarize-helpers', () => { const courses: Schedule['courses'] = [ @@ -66,9 +67,9 @@ describe('calendarize-helpers', () => { ]; // 3 of the same event - const calendarizedCourses = [ + const calendarizedCourses: CourseEvent[] = [ { - bldg: undefined, + locations: [], color: 'placeholderColor', term: 'placeholderTerm', title: 'placeholderDeptCode placeholderCourseNumber', @@ -91,12 +92,13 @@ describe('calendarize-helpers', () => { hour: 3, minute: 4, }, - bldg: [], + locations: [], }, + showLocationInfo: false, isCustomEvent: false, }, { - bldg: undefined, + locations: [], color: 'placeholderColor', term: 'placeholderTerm', title: 'placeholderDeptCode placeholderCourseNumber', @@ -119,12 +121,13 @@ describe('calendarize-helpers', () => { hour: 3, minute: 4, }, - bldg: [], + locations: [], }, + showLocationInfo: false, isCustomEvent: false, }, { - bldg: undefined, + locations: [], color: 'placeholderColor', term: 'placeholderTerm', title: 'placeholderDeptCode placeholderCourseNumber', @@ -147,15 +150,16 @@ describe('calendarize-helpers', () => { hour: 3, minute: 4, }, - bldg: [], + locations: [], }, + showLocationInfo: false, isCustomEvent: false, }, ]; - const calendarizedCourseFinals = [ + const calendarizedCourseFinals: CourseEvent[] = [ { - bldg: undefined, + locations: [], color: 'placeholderColor', term: 'placeholderTerm', title: 'placeholderDeptCode placeholderCourseNumber', @@ -178,8 +182,9 @@ describe('calendarize-helpers', () => { hour: 3, minute: 4, }, - bldg: [], + locations: [], }, + showLocationInfo: true, isCustomEvent: false, }, ]; @@ -195,7 +200,7 @@ describe('calendarize-helpers', () => { }, ]; - const calendarizedCustomEvents = [ + const calendarizedCustomEvents: CustomEvent[] = [ { isCustomEvent: true, customEventID: 0, From d310f3a7c86d5e64b82022402b04d5ca2c709fbd Mon Sep 17 00:00:00 2001 From: Minh Nguyen <64875104+MinhxNguyen7@users.noreply.github.com> Date: Sat, 16 Sep 2023 11:05:29 -0700 Subject: [PATCH 74/89] Remove underline from location link (#699) --- .../components/RightPane/SectionTable/SectionTableBody.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx index 4720befd5..2cc931507 100644 --- a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx +++ b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx @@ -70,6 +70,8 @@ const styles: Styles = (theme) => ({ background: 'none !important', border: 'none', padding: '0 !important', + fontSize: '0.85rem', // Not sure why this is not inherited + textDecoration: 'none', }, paper: { padding: theme.spacing(), @@ -315,7 +317,7 @@ const LocationsCell = withStyles(styles)((props: LocationsCellProps) => { return ( From 8d37524e0fc033c231fe2d58b4d38fa96bf59f80 Mon Sep 17 00:00:00 2001 From: Sean Kelman Date: Thu, 21 Sep 2023 16:29:23 -0700 Subject: [PATCH 75/89] fixing behavior of buttons in AddedPane (#703) --- .../components/RightPane/AddedCourses/AddedCoursePane.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/antalmanac/src/components/RightPane/AddedCourses/AddedCoursePane.tsx b/apps/antalmanac/src/components/RightPane/AddedCourses/AddedCoursePane.tsx index 6948a7748..facdb8b14 100644 --- a/apps/antalmanac/src/components/RightPane/AddedCourses/AddedCoursePane.tsx +++ b/apps/antalmanac/src/components/RightPane/AddedCourses/AddedCoursePane.tsx @@ -296,13 +296,12 @@ function AddedSectionsGrid() { return ( - + - - + {`${scheduleName} (${scheduleUnits} Units)`} {courses.map((course) => { From 9b1484028679bce86de1b8132de69dfc82a0eb01 Mon Sep 17 00:00:00 2001 From: Eric Pedley Date: Tue, 26 Sep 2023 21:44:08 -0400 Subject: [PATCH 76/89] Make it clear you import a .ics file to other calendars. (#706) Co-authored-by: Kevin Wu --- .../antalmanac/src/components/AppBar/Exports/ExportCalendar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/antalmanac/src/components/AppBar/Exports/ExportCalendar.tsx b/apps/antalmanac/src/components/AppBar/Exports/ExportCalendar.tsx index 1762c0b0f..404df7853 100644 --- a/apps/antalmanac/src/components/AppBar/Exports/ExportCalendar.tsx +++ b/apps/antalmanac/src/components/AppBar/Exports/ExportCalendar.tsx @@ -4,7 +4,7 @@ import { Download } from '@mui/icons-material'; import { exportCalendar } from '$lib/download'; const ExportCalendarButton = () => ( - + From 3368692a6898ff453a073382e9096bdcd1bbbf2e Mon Sep 17 00:00:00 2001 From: Kevin Wu Date: Wed, 27 Sep 2023 14:23:00 -0700 Subject: [PATCH 77/89] Added Analytics to Column Toggle (#707) --- apps/antalmanac/src/lib/analytics.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/antalmanac/src/lib/analytics.ts b/apps/antalmanac/src/lib/analytics.ts index 7440354a2..3be891605 100644 --- a/apps/antalmanac/src/lib/analytics.ts +++ b/apps/antalmanac/src/lib/analytics.ts @@ -49,6 +49,7 @@ const analyticsEnum = { ADD_SPECIFIC: 'Add Course to Specific Schedule', COPY_COURSE_CODE: 'Copy Course Code', REFRESH: 'Refresh Results', + TOGGLE_COLUMNS: 'Toggle Columns', }, }, addedClasses: { From ba8c973fd80cec953b1362dea563c4776d3c6a74 Mon Sep 17 00:00:00 2001 From: Eric Pedley Date: Thu, 28 Sep 2023 19:42:40 -0400 Subject: [PATCH 78/89] Update fuzzy search index (#710) --- apps/antalmanac/package.json | 2 +- pnpm-lock.yaml | 50 ++++++++++++++++++------------------ 2 files changed, 26 insertions(+), 26 deletions(-) diff --git a/apps/antalmanac/package.json b/apps/antalmanac/package.json index 379cc2bfd..fb29c0dd6 100644 --- a/apps/antalmanac/package.json +++ b/apps/antalmanac/package.json @@ -64,7 +64,7 @@ "react-split": "^2.0.14", "recharts": "^2.4.2", "superjson": "^1.12.3", - "websoc-fuzzy-search": "^0.10.0", + "websoc-fuzzy-search": "^1.0.1", "zustand": "^4.3.2" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ca870c7f9..8f4f683b4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,7 +25,7 @@ importers: version: 2.8.4 turbo: specifier: latest - version: 1.10.13 + version: 1.10.14 vitest: specifier: ^0.34.4 version: 0.34.4(jsdom@22.1.0) @@ -165,8 +165,8 @@ importers: specifier: ^1.12.3 version: 1.12.3 websoc-fuzzy-search: - specifier: ^0.10.0 - version: 0.10.0 + specifier: ^1.0.1 + version: 1.0.1 zustand: specifier: ^4.3.2 version: 4.3.3(react@18.2.0) @@ -8313,64 +8313,64 @@ packages: fsevents: 2.3.2 dev: true - /turbo-darwin-64@1.10.13: - resolution: {integrity: sha512-vmngGfa2dlYvX7UFVncsNDMuT4X2KPyPJ2Jj+xvf5nvQnZR/3IeDEGleGVuMi/hRzdinoxwXqgk9flEmAYp0Xw==} + /turbo-darwin-64@1.10.14: + resolution: {integrity: sha512-I8RtFk1b9UILAExPdG/XRgGQz95nmXPE7OiGb6ytjtNIR5/UZBS/xVX/7HYpCdmfriKdVwBKhalCoV4oDvAGEg==} cpu: [x64] os: [darwin] requiresBuild: true dev: true optional: true - /turbo-darwin-arm64@1.10.13: - resolution: {integrity: sha512-eMoJC+k7gIS4i2qL6rKmrIQGP6Wr9nN4odzzgHFngLTMimok2cGLK3qbJs5O5F/XAtEeRAmuxeRnzQwTl/iuAw==} + /turbo-darwin-arm64@1.10.14: + resolution: {integrity: sha512-KAdUWryJi/XX7OD0alOuOa0aJ5TLyd4DNIYkHPHYcM6/d7YAovYvxRNwmx9iv6Vx6IkzTnLeTiUB8zy69QkG9Q==} cpu: [arm64] os: [darwin] requiresBuild: true dev: true optional: true - /turbo-linux-64@1.10.13: - resolution: {integrity: sha512-0CyYmnKTs6kcx7+JRH3nPEqCnzWduM0hj8GP/aodhaIkLNSAGAa+RiYZz6C7IXN+xUVh5rrWTnU2f1SkIy7Gdg==} + /turbo-linux-64@1.10.14: + resolution: {integrity: sha512-BOBzoREC2u4Vgpap/WDxM6wETVqVMRcM8OZw4hWzqCj2bqbQ6L0wxs1LCLWVrghQf93JBQtIGAdFFLyCSBXjWQ==} cpu: [x64] os: [linux] requiresBuild: true dev: true optional: true - /turbo-linux-arm64@1.10.13: - resolution: {integrity: sha512-0iBKviSGQQlh2OjZgBsGjkPXoxvRIxrrLLbLObwJo3sOjIH0loGmVIimGS5E323soMfi/o+sidjk2wU1kFfD7Q==} + /turbo-linux-arm64@1.10.14: + resolution: {integrity: sha512-D8T6XxoTdN5D4V5qE2VZG+/lbZX/89BkAEHzXcsSUTRjrwfMepT3d2z8aT6hxv4yu8EDdooZq/2Bn/vjMI32xw==} cpu: [arm64] os: [linux] requiresBuild: true dev: true optional: true - /turbo-windows-64@1.10.13: - resolution: {integrity: sha512-S5XySRfW2AmnTeY1IT+Jdr6Goq7mxWganVFfrmqU+qqq3Om/nr0GkcUX+KTIo9mPrN0D3p5QViBRzulwB5iuUQ==} + /turbo-windows-64@1.10.14: + resolution: {integrity: sha512-zKNS3c1w4i6432N0cexZ20r/aIhV62g69opUn82FLVs/zk3Ie0GVkSB6h0rqIvMalCp7enIR87LkPSDGz9K4UA==} cpu: [x64] os: [win32] requiresBuild: true dev: true optional: true - /turbo-windows-arm64@1.10.13: - resolution: {integrity: sha512-nKol6+CyiExJIuoIc3exUQPIBjP9nIq5SkMJgJuxsot2hkgGrafAg/izVDRDrRduQcXj2s8LdtxJHvvnbI8hEQ==} + /turbo-windows-arm64@1.10.14: + resolution: {integrity: sha512-rkBwrTPTxNSOUF7of8eVvvM+BkfkhA2OvpHM94if8tVsU+khrjglilp8MTVPHlyS9byfemPAmFN90oRIPB05BA==} cpu: [arm64] os: [win32] requiresBuild: true dev: true optional: true - /turbo@1.10.13: - resolution: {integrity: sha512-vOF5IPytgQPIsgGtT0n2uGZizR2N3kKuPIn4b5p5DdeLoI0BV7uNiydT7eSzdkPRpdXNnO8UwS658VaI4+YSzQ==} + /turbo@1.10.14: + resolution: {integrity: sha512-hr9wDNYcsee+vLkCDIm8qTtwhJ6+UAMJc3nIY6+PNgUTtXcQgHxCq8BGoL7gbABvNWv76CNbK5qL4Lp9G3ZYRA==} hasBin: true optionalDependencies: - turbo-darwin-64: 1.10.13 - turbo-darwin-arm64: 1.10.13 - turbo-linux-64: 1.10.13 - turbo-linux-arm64: 1.10.13 - turbo-windows-64: 1.10.13 - turbo-windows-arm64: 1.10.13 + turbo-darwin-64: 1.10.14 + turbo-darwin-arm64: 1.10.14 + turbo-linux-64: 1.10.14 + turbo-linux-arm64: 1.10.14 + turbo-windows-64: 1.10.14 + turbo-windows-arm64: 1.10.14 dev: true /type-check@0.4.0: @@ -8751,8 +8751,8 @@ packages: node-fetch: 3.3.1 dev: false - /websoc-fuzzy-search@0.10.0: - resolution: {integrity: sha512-P9lbkccsRm0y1OghGsLkzyM9+/b6LsACoz6Y0VZhfA9rv+16fb3UWFoTe7ui4wnjkXMpcr9lkDfe1Cs0GszpZQ==} + /websoc-fuzzy-search@1.0.1: + resolution: {integrity: sha512-1UlDdT2OvMxVIczNSQzI+vSoojfagbORdwtMQiLAnG1zVLG9Po6x5+VWNysi8w5xoxE2NootQH72HzoenLygDg==} dependencies: base64-arraybuffer: 1.0.2 pako: 2.1.0 From c7efe15167a1c43201b503ae46116921420960c5 Mon Sep 17 00:00:00 2001 From: Kevin Wu Date: Fri, 29 Sep 2023 16:11:14 -0700 Subject: [PATCH 79/89] Fix Undefined Trim Bug (#709) Co-authored-by: Aponia --- .../src/components/dialogs/RenameSchedule.tsx | 10 +++++++--- apps/antalmanac/tests/schedule.test.ts | 10 ---------- apps/antalmanac/tests/schedule.test.tsx | 16 ++++++++++++++++ 3 files changed, 23 insertions(+), 13 deletions(-) delete mode 100644 apps/antalmanac/tests/schedule.test.ts create mode 100644 apps/antalmanac/tests/schedule.test.tsx diff --git a/apps/antalmanac/src/components/dialogs/RenameSchedule.tsx b/apps/antalmanac/src/components/dialogs/RenameSchedule.tsx index 50f761833..8ee66fc06 100644 --- a/apps/antalmanac/src/components/dialogs/RenameSchedule.tsx +++ b/apps/antalmanac/src/components/dialogs/RenameSchedule.tsx @@ -1,4 +1,4 @@ -import { useCallback, useState, useEffect } from 'react'; +import { useCallback, useState, useEffect, useMemo } from 'react'; import { Box, Button, @@ -39,6 +39,10 @@ function RenameScheduleDialog(props: ScheduleNameDialogProps) { const [name, setName] = useState(scheduleNames[index]); + const disabled = useMemo(() => { + return name?.trim() === ''; + }, [name]); + const handleCancel = useCallback(() => { onClose?.({}, 'escapeKeyDown'); setName(scheduleNames[index]); @@ -93,10 +97,10 @@ function RenameScheduleDialog(props: ScheduleNameDialogProps) { - - diff --git a/apps/antalmanac/tests/schedule.test.ts b/apps/antalmanac/tests/schedule.test.ts deleted file mode 100644 index 09894443a..000000000 --- a/apps/antalmanac/tests/schedule.test.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { describe, test, expect } from 'vitest'; -import { Schedules } from '../src/stores/Schedules'; - -describe('schedule logic', () => { - const scheduleStore = new Schedules(); - - test('no error when loading undefined schedule', () => { - expect(() => scheduleStore.getScheduleName(69)).not.toThrowError(); - }); -}); diff --git a/apps/antalmanac/tests/schedule.test.tsx b/apps/antalmanac/tests/schedule.test.tsx new file mode 100644 index 000000000..41e4d890f --- /dev/null +++ b/apps/antalmanac/tests/schedule.test.tsx @@ -0,0 +1,16 @@ +import { describe, test, expect } from 'vitest'; +import { render } from '@testing-library/react'; +import { Schedules } from '$stores/Schedules'; +import RenameScheduleDialog from '$components/dialogs/RenameSchedule'; + +describe('schedule logic', () => { + const scheduleStore = new Schedules(); + + test('no error when loading undefined schedule', () => { + expect(() => scheduleStore.getScheduleName(100)).not.toThrowError(); + }); + + test('does not error if schedule name is undefined', () => { + expect(() => render()).not.toThrowError(); + }); +}); From 67c7b0db574e0654f7e5372084fb29f9e3e4ad63 Mon Sep 17 00:00:00 2001 From: Kevin Wu Date: Fri, 29 Sep 2023 16:13:56 -0700 Subject: [PATCH 80/89] Fix Added Pane Course Grouping (#711) --- .../components/RightPane/AddedCourses/AddedCoursePane.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/antalmanac/src/components/RightPane/AddedCourses/AddedCoursePane.tsx b/apps/antalmanac/src/components/RightPane/AddedCourses/AddedCoursePane.tsx index facdb8b14..0981b0d82 100644 --- a/apps/antalmanac/src/components/RightPane/AddedCourses/AddedCoursePane.tsx +++ b/apps/antalmanac/src/components/RightPane/AddedCourses/AddedCoursePane.tsx @@ -54,7 +54,9 @@ function getCourses() { for (const course of currentCourses) { let formattedCourse = formattedCourses.find( (needleCourse) => - needleCourse.courseNumber === course.courseNumber && needleCourse.deptCode === course.deptCode + needleCourse.courseNumber === course.courseNumber && + needleCourse.deptCode === course.deptCode && + needleCourse.courseTitle === course.courseTitle ); if (formattedCourse) { @@ -306,7 +308,7 @@ function AddedSectionsGrid() { {courses.map((course) => { return ( - + Date: Sat, 30 Sep 2023 14:50:03 -0700 Subject: [PATCH 81/89] Update reviewer lottery roster --- .github/reviewer-lottery.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/reviewer-lottery.yml b/.github/reviewer-lottery.yml index 8afaf524e..87f8309fc 100644 --- a/.github/reviewer-lottery.yml +++ b/.github/reviewer-lottery.yml @@ -7,9 +7,7 @@ groups: - bevm0 - MinhxNguyen7 - stevenguyukai - - johnlorenzini - Douglas-Hong - teresa-liang - - mina03333 - JacE070 - \ No newline at end of file + From f70aa8df72b426131279b601769c1a2381fc8780 Mon Sep 17 00:00:00 2001 From: Eric Pedley Date: Sun, 1 Oct 2023 17:30:23 -0400 Subject: [PATCH 82/89] Parallelize Schedule Load Websoc Requests (#713) Co-authored-by: Aponia --- apps/antalmanac/src/stores/Schedules.ts | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/apps/antalmanac/src/stores/Schedules.ts b/apps/antalmanac/src/stores/Schedules.ts index 60fb67923..6443b9b06 100644 --- a/apps/antalmanac/src/stores/Schedules.ts +++ b/apps/antalmanac/src/stores/Schedules.ts @@ -461,17 +461,14 @@ export class Schedules { // Get the course info for each course const courseInfoDict = new Map(); - for (const [term, courseSet] of Object.entries(courseDict)) { - const sectionCodes = Array.from(courseSet); - // Code from ImportStudyList - const courseInfo = getCourseInfo( - await queryWebsoc({ - term: term, - sectionCodes: sectionCodes.join(','), - }) - ); + + const websocRequests = Object.entries(courseDict).map(async ([term, courseSet]) => { + const sectionCodes = Array.from(courseSet).join(','); + const courseInfo = getCourseInfo(await queryWebsoc({ term, sectionCodes })); courseInfoDict.set(term, courseInfo); - } + }); + + await Promise.all(websocRequests); // Map course info to courses and transform shortened schedule to normal schedule for (const shortCourseSchedule of saveState.schedules) { From 192a4b100f8c4597ab56fe3cafe00322eeaa4e7a Mon Sep 17 00:00:00 2001 From: MinhxNguyen7 <64875104+MinhxNguyen7@users.noreply.github.com> Date: Sun, 1 Oct 2023 22:50:35 +0000 Subject: [PATCH 83/89] Re-add column analytics trigger --- apps/antalmanac/src/stores/ColumnStore.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/antalmanac/src/stores/ColumnStore.ts b/apps/antalmanac/src/stores/ColumnStore.ts index 0e11a2ae7..6f3d08cee 100644 --- a/apps/antalmanac/src/stores/ColumnStore.ts +++ b/apps/antalmanac/src/stores/ColumnStore.ts @@ -1,4 +1,5 @@ import { create } from 'zustand'; +import analyticsEnum, { logAnalytics } from '$lib/analytics'; /** * Search results are displayed in a tabular format. @@ -66,6 +67,10 @@ export const useColumnStore = create((set, get) => { const selectedColumns = SECTION_TABLE_COLUMNS.map((column) => columns.includes(column)); return { selectedColumns: selectedColumns }; }); + logAnalytics({ + category: analyticsEnum.classSearch.title, + action: analyticsEnum.classSearch.actions.TOGGLE_COLUMNS, + }); }, setColumnEnabled: (column: SectionTableColumn, state: boolean) => { set((prevState) => { From 722940556528789b3c54e3f22cf4ce1906837640 Mon Sep 17 00:00:00 2001 From: Minh Nguyen <64875104+MinhxNguyen7@users.noreply.github.com> Date: Sat, 7 Oct 2023 16:55:56 -0700 Subject: [PATCH 84/89] Update graphql/grades endpoint to PP prod --- apps/antalmanac/src/lib/api/endpoints.ts | 2 +- apps/antalmanac/src/lib/grades.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/antalmanac/src/lib/api/endpoints.ts b/apps/antalmanac/src/lib/api/endpoints.ts index 48d49442b..3cb2298e0 100644 --- a/apps/antalmanac/src/lib/api/endpoints.ts +++ b/apps/antalmanac/src/lib/api/endpoints.ts @@ -15,7 +15,7 @@ export const LOOKUP_NOTIFICATIONS_ENDPOINT = endpointTransform('/api/notificatio export const REGISTER_NOTIFICATIONS_ENDPOINT = endpointTransform('/api/notifications/registerNotifications'); // PeterPortal API -export const PETERPORTAL_GRAPHQL_ENDPOINT = 'https://staging-88.api-next.peterportal.org/v1/graphql'; +export const PETERPORTAL_GRAPHQL_ENDPOINT = 'https://api-next.peterportal.org/v1/graphql'; export const PETERPORTAL_REST_ENDPOINT = 'https://api-next.peterportal.org/v1/rest'; export const PETERPORTAL_WEBSOC_ENDPOINT = `${PETERPORTAL_REST_ENDPOINT}/websoc`; diff --git a/apps/antalmanac/src/lib/grades.ts b/apps/antalmanac/src/lib/grades.ts index 8e0d5d8e9..3508d8702 100644 --- a/apps/antalmanac/src/lib/grades.ts +++ b/apps/antalmanac/src/lib/grades.ts @@ -143,7 +143,7 @@ class _Grades { if (cacheOnly) return null; const queryString = `{ - aggregateGrades(department: "${deptCode}", courseNumber: "${courseNumber}", ${instructorFilter}) { + aggregateByOffering(department: "${deptCode}", courseNumber: "${courseNumber}", ${instructorFilter}) { gradeDistribution { gradeACount gradeBCount From 24d116e3901dda51189ef26a58cc0fe2a8c81a4d Mon Sep 17 00:00:00 2001 From: Minh Nguyen <64875104+MinhxNguyen7@users.noreply.github.com> Date: Sat, 7 Oct 2023 17:07:25 -0700 Subject: [PATCH 85/89] Apply review suggestions --- .../CoursePane/CoursePaneButtonRow.tsx | 21 +++++++++---------- .../RightPane/SectionTable/SectionTable.tsx | 5 ++++- apps/antalmanac/src/stores/ColumnStore.ts | 2 +- 3 files changed, 15 insertions(+), 13 deletions(-) diff --git a/apps/antalmanac/src/components/RightPane/CoursePane/CoursePaneButtonRow.tsx b/apps/antalmanac/src/components/RightPane/CoursePane/CoursePaneButtonRow.tsx index c8dfc644c..19e816018 100644 --- a/apps/antalmanac/src/components/RightPane/CoursePane/CoursePaneButtonRow.tsx +++ b/apps/antalmanac/src/components/RightPane/CoursePane/CoursePaneButtonRow.tsx @@ -53,20 +53,25 @@ function renderEmptySelectValue() { return ''; } +const COLUMN_LABEL_ENTRIES = Object.entries(columnLabels); + /** * Toggles certain columns on/off. * * e.g. show/hide the section code, instructors, etc. */ export function ColumnToggleButton() { - const [ selectedColumns, setSelectedColumns ] = useColumnStore(store => [store.selectedColumns, store.setSelectedColumns]); + const [selectedColumns, setSelectedColumns] = useColumnStore((store) => [ + store.selectedColumns, + store.setSelectedColumns, + ]); const [open, setOpen] = useState(false); - const handleChange = (e: SelectChangeEvent) => { + const handleChange = useCallback((e: SelectChangeEvent) => { if (typeof e.target.value !== 'string') { setSelectedColumns(e.target.value); } - }; + }, []); const handleOpen = useCallback(() => { setOpen(true); @@ -81,12 +86,6 @@ export function ColumnToggleButton() { [selectedColumns] ); - const columnLabelEntries = useMemo( - () => Object.entries(columnLabels), - [] - ); - - return ( <> @@ -104,9 +103,9 @@ export function ColumnToggleButton() { renderValue={renderEmptySelectValue} sx={{ visibility: 'hidden', position: 'absolute' }} > - {columnLabelEntries.map(([column, label], idx) => ( + {COLUMN_LABEL_ENTRIES.map(([column, label], index) => ( - + ))} diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTable.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTable.tsx index 5017c1659..945e279d8 100644 --- a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTable.tsx +++ b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTable.tsx @@ -104,7 +104,10 @@ function EnrollmentColumnHeader(props: EnrollmentColumnHeaderProps) { function SectionTable(props: SectionTableProps) { const { courseDetails, term, allowHighlight, scheduleNames, analyticsCategory } = props; - const { selectedColumns, getActiveColumns } = useColumnStore(); + const [selectedColumns, getActiveColumns] = useColumnStore((store) => [ + store.selectedColumns, + store.getActiveColumns, + ]); const isMobileScreen = useMediaQuery(`(max-width: ${MOBILE_BREAKPOINT})`); diff --git a/apps/antalmanac/src/stores/ColumnStore.ts b/apps/antalmanac/src/stores/ColumnStore.ts index 6f3d08cee..f0101babc 100644 --- a/apps/antalmanac/src/stores/ColumnStore.ts +++ b/apps/antalmanac/src/stores/ColumnStore.ts @@ -57,7 +57,7 @@ interface ColumnStore { */ export const useColumnStore = create((set, get) => { return { - activeColumns: [...SECTION_TABLE_COLUMNS], + activeColumns: Array.from(SECTION_TABLE_COLUMNS), enabledColumns: SECTION_TABLE_COLUMNS.map(() => true), selectedColumns: SECTION_TABLE_COLUMNS.map(() => true), getActiveColumns: () => From 537a996b76a1c4798438c6c2a0eed53ffb361203 Mon Sep 17 00:00:00 2001 From: Minh Nguyen <64875104+MinhxNguyen7@users.noreply.github.com> Date: Sun, 8 Oct 2023 16:25:31 -0700 Subject: [PATCH 86/89] Make activeColumns dervied in ColumnStore --- .../RightPane/SectionTable/SectionTable.tsx | 9 +++----- .../SectionTable/SectionTableBody.tsx | 10 +++------ apps/antalmanac/src/stores/ColumnStore.ts | 22 ++++++++++--------- 3 files changed, 18 insertions(+), 23 deletions(-) diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTable.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTable.tsx index 945e279d8..e32f1134d 100644 --- a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTable.tsx +++ b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTable.tsx @@ -104,10 +104,7 @@ function EnrollmentColumnHeader(props: EnrollmentColumnHeaderProps) { function SectionTable(props: SectionTableProps) { const { courseDetails, term, allowHighlight, scheduleNames, analyticsCategory } = props; - const [selectedColumns, getActiveColumns] = useColumnStore((store) => [ - store.selectedColumns, - store.getActiveColumns, - ]); + const [selectedColumns, activeColumns] = useColumnStore((store) => [store.selectedColumns, store.activeColumns]); const isMobileScreen = useMediaQuery(`(max-width: ${MOBILE_BREAKPOINT})`); @@ -124,7 +121,7 @@ function SectionTable(props: SectionTableProps) { */ const tableMinWidth = useMemo(() => { const width = isMobileScreen ? 600 : 780; - const numActiveColumns = getActiveColumns().length; + const numActiveColumns = activeColumns.length; return (width * numActiveColumns) / TOTAL_NUM_COLUMNS; }, [isMobileScreen, selectedColumns]); @@ -180,7 +177,7 @@ function SectionTable(props: SectionTableProps) { {tableHeaderColumnEntries - .filter(([column]) => getActiveColumns().includes(column as SectionTableColumn)) + .filter(([column]) => activeColumns.includes(column as SectionTableColumn)) .map(([column, { label, width }]) => ( { const buildingId = locationIds[buildingName]; return ( - + {bldg}
@@ -498,7 +494,7 @@ const tableBodyCells: Record> = { const SectionTableBody = withStyles(styles)((props: SectionTableBodyProps) => { const { classes, section, courseDetails, term, allowHighlight, scheduleNames } = props; - const getActiveColumns = useColumnStore(store => store.getActiveColumns); + const activeColumns = useColumnStore((store) => store.activeColumns); const [addedCourse, setAddedCourse] = useState( AppStore.getAddedSectionCodes().has(`${section.sectionCode} ${term}`) @@ -616,7 +612,7 @@ const SectionTableBody = withStyles(styles)((props: SectionTableBodyProps) => { )} {Object.entries(tableBodyCells) - .filter(([column]) => getActiveColumns().includes(column as SectionTableColumn)) + .filter(([column]) => activeColumns.includes(column as SectionTableColumn)) .map(([column, Component]) => { return ( // All of this is a little bulky, so if the props can be added specifically to activeTableBodyColumns, LMK! diff --git a/apps/antalmanac/src/stores/ColumnStore.ts b/apps/antalmanac/src/stores/ColumnStore.ts index f0101babc..78707c3ca 100644 --- a/apps/antalmanac/src/stores/ColumnStore.ts +++ b/apps/antalmanac/src/stores/ColumnStore.ts @@ -30,11 +30,9 @@ interface ColumnStore { // Columns that are selected in the dropdown selectedColumns: boolean[]; - /** - * - * @returns The columns that should be displayed in the section table. They need to be both selected and enabled. - */ - getActiveColumns: () => SectionTableColumn[]; + // Columns that should be displayed in the section table. They need to be both selected and enabled. + // This is updated whenever enabledColumns or selectedColumns are updated. + activeColumns: SectionTableColumn[]; /** * Used by the Select menu in CoursePaneButtonRow to toggle columns on/off. @@ -57,15 +55,16 @@ interface ColumnStore { */ export const useColumnStore = create((set, get) => { return { - activeColumns: Array.from(SECTION_TABLE_COLUMNS), enabledColumns: SECTION_TABLE_COLUMNS.map(() => true), selectedColumns: SECTION_TABLE_COLUMNS.map(() => true), - getActiveColumns: () => - SECTION_TABLE_COLUMNS.filter((_, index) => get().enabledColumns[index] && get().selectedColumns[index]), + activeColumns: Array.from(SECTION_TABLE_COLUMNS), setSelectedColumns: (columns: SectionTableColumn[]) => { set(() => { const selectedColumns = SECTION_TABLE_COLUMNS.map((column) => columns.includes(column)); - return { selectedColumns: selectedColumns }; + const activeColumns = SECTION_TABLE_COLUMNS.filter( + (_, index) => get().enabledColumns[index] && get().selectedColumns[index] + ); + return { selectedColumns: selectedColumns, activeColumns: activeColumns }; }); logAnalytics({ category: analyticsEnum.classSearch.title, @@ -75,7 +74,10 @@ export const useColumnStore = create((set, get) => { setColumnEnabled: (column: SectionTableColumn, state: boolean) => { set((prevState) => { prevState.enabledColumns[SECTION_TABLE_COLUMNS.indexOf(column)] = state; - return { enabledColumns: prevState.enabledColumns }; + const activeColumns = SECTION_TABLE_COLUMNS.filter( + (_, index) => prevState.enabledColumns[index] && prevState.selectedColumns[index] + ); + return { enabledColumns: prevState.enabledColumns, activeColumns: activeColumns }; }); }, }; From b77c0c75ab17f844629f9558e0d77a3e4cfb1a23 Mon Sep 17 00:00:00 2001 From: Minh Nguyen <64875104+MinhxNguyen7@users.noreply.github.com> Date: Tue, 10 Oct 2023 20:32:32 +0000 Subject: [PATCH 87/89] update dependancy of tableMinWidth in SectionTable --- .../src/components/RightPane/SectionTable/SectionTable.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTable.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTable.tsx index e32f1134d..df3f08769 100644 --- a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTable.tsx +++ b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTable.tsx @@ -123,7 +123,7 @@ function SectionTable(props: SectionTableProps) { const width = isMobileScreen ? 600 : 780; const numActiveColumns = activeColumns.length; return (width * numActiveColumns) / TOTAL_NUM_COLUMNS; - }, [isMobileScreen, selectedColumns]); + }, [isMobileScreen, activeColumns]); return ( <> From ef40ee2f9122964e6ac2eca6b746438367771810 Mon Sep 17 00:00:00 2001 From: Minh Nguyen <64875104+MinhxNguyen7@users.noreply.github.com> Date: Tue, 10 Oct 2023 20:47:12 +0000 Subject: [PATCH 88/89] Merge branch 'main' into gpa --- apps/antalmanac/src/components/Map/Map.tsx | 2 +- .../RightPane/CoursePane/CoursePaneRoot.tsx | 113 ++++---- .../RightPane/CoursePane/CourseRenderPane.tsx | 264 ++++++++---------- 3 files changed, 163 insertions(+), 216 deletions(-) diff --git a/apps/antalmanac/src/components/Map/Map.tsx b/apps/antalmanac/src/components/Map/Map.tsx index 242f478bb..2f33e5715 100644 --- a/apps/antalmanac/src/components/Map/Map.tsx +++ b/apps/antalmanac/src/components/Map/Map.tsx @@ -217,7 +217,7 @@ export default function CourseMap() { {/* Menu floats above the map. */} - + {days.map((day) => ( diff --git a/apps/antalmanac/src/components/RightPane/CoursePane/CoursePaneRoot.tsx b/apps/antalmanac/src/components/RightPane/CoursePane/CoursePaneRoot.tsx index 0092e2fac..f953dda83 100644 --- a/apps/antalmanac/src/components/RightPane/CoursePane/CoursePaneRoot.tsx +++ b/apps/antalmanac/src/components/RightPane/CoursePane/CoursePaneRoot.tsx @@ -1,5 +1,4 @@ -import { withStyles } from '@material-ui/core/styles'; -import { PureComponent } from 'react'; +import { useCallback, useEffect, useReducer } from 'react'; import RightPaneStore from '../RightPaneStore'; import CoursePaneButtonRow from './CoursePaneButtonRow'; @@ -9,50 +8,10 @@ import analyticsEnum, { logAnalytics } from '$lib/analytics'; import { openSnackbar } from '$actions/AppStoreActions'; import { clearCache } from '$lib/course-helpers'; -const styles = { - container: { - height: '100%', - }, -}; +function RightPane() { + const [key, forceUpdate] = useReducer((currentCount) => currentCount + 1, 0); -class RightPane extends PureComponent { - // When a user clicks the refresh button in CoursePaneButtonRow, - // we increment the refresh state by 1. - // Since it's the key for CourseRenderPane, it triggers a rerender - // and reloads the latest course data - state = { - refresh: 0, - }; - - returnToSearchBarEvent = (event: KeyboardEvent) => { - if ( - !(RightPaneStore.getDoDisplaySearch() || RightPaneStore.getOpenSpotAlertPopoverActive()) && - (event.key === 'Backspace' || event.key === 'Escape') - ) { - event.preventDefault(); - RightPaneStore.toggleSearch(); - this.forceUpdate(); - } - }; - - componentDidMount() { - document.addEventListener('keydown', this.returnToSearchBarEvent, false); - } - - componentWillUnmount() { - document.removeEventListener('keydown', this.returnToSearchBarEvent, false); - } - - refreshSearch = () => { - logAnalytics({ - category: analyticsEnum.classSearch.title, - action: analyticsEnum.classSearch.actions.REFRESH, - }); - clearCache(); - this.setState({ refresh: this.state.refresh + 1 }); - }; - - toggleSearch = () => { + const toggleSearch = useCallback(() => { if ( RightPaneStore.getFormData().ge !== 'ANY' || RightPaneStore.getFormData().deptValue !== 'ALL' || @@ -60,31 +19,57 @@ class RightPane extends PureComponent { RightPaneStore.getFormData().instructor !== '' ) { RightPaneStore.toggleSearch(); - this.forceUpdate(); + forceUpdate(); } else { openSnackbar( 'error', `Please provide one of the following: Department, GE, Course Code/Range, or Instructor` ); } - }; + }, []); + + const refreshSearch = useCallback(() => { + logAnalytics({ + category: analyticsEnum.classSearch.title, + action: analyticsEnum.classSearch.actions.REFRESH, + }); + clearCache(); + forceUpdate(); + }, []); + + useEffect(() => { + const handleReturnToSearch = (event: KeyboardEvent) => { + if ( + !(RightPaneStore.getDoDisplaySearch() || RightPaneStore.getOpenSpotAlertPopoverActive()) && + (event.key === 'Backspace' || event.key === 'Escape') + ) { + event.preventDefault(); + RightPaneStore.toggleSearch(); + forceUpdate(); + } + }; + + document.addEventListener('keydown', handleReturnToSearch, false); + + return () => { + document.removeEventListener('keydown', handleReturnToSearch, false); + }; + }, []); - render() { - return ( -
- - {RightPaneStore.getDoDisplaySearch() ? ( - - ) : ( - - )} -
- ); - } + return ( +
+ + {RightPaneStore.getDoDisplaySearch() ? ( + + ) : ( + + )} +
+ ); } -export default withStyles(styles)(RightPane); +export default RightPane; diff --git a/apps/antalmanac/src/components/RightPane/CoursePane/CourseRenderPane.tsx b/apps/antalmanac/src/components/RightPane/CoursePane/CourseRenderPane.tsx index b1906519a..2a67fe9f6 100644 --- a/apps/antalmanac/src/components/RightPane/CoursePane/CourseRenderPane.tsx +++ b/apps/antalmanac/src/components/RightPane/CoursePane/CourseRenderPane.tsx @@ -1,8 +1,6 @@ -import { IconButton, Theme } from '@material-ui/core'; -import { withStyles } from '@material-ui/core/styles'; -import { ClassNameMap, Styles } from '@material-ui/core/styles/withStyles'; +import { IconButton } from '@material-ui/core'; import CloseIcon from '@material-ui/icons/Close'; -import React, { PureComponent } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import LazyLoad from 'react-lazyload'; import { Alert } from '@mui/material'; @@ -21,62 +19,7 @@ import { isDarkMode, queryWebsoc, queryWebsocMultiple } from '$lib/helpers'; import Grades from '$lib/grades'; import analyticsEnum from '$lib/analytics'; -const styles: Styles = (theme) => ({ - course: { - paddingLeft: theme.spacing(2), - paddingRight: theme.spacing(2), - [theme.breakpoints.up('sm')]: { - paddingLeft: theme.spacing(3), - paddingRight: theme.spacing(3), - }, - paddingTop: theme.spacing(), - paddingBottom: theme.spacing(), - display: 'flex', - alignItems: 'center', - flexWrap: 'wrap', - minHeight: theme.spacing(6), - cursor: 'pointer', - }, - text: { - flexGrow: 1, - display: 'inline', - width: '100%', - }, - ad: { - flexGrow: 1, - display: 'inline', - width: '100%', - }, - icon: { - cursor: 'pointer', - marginLeft: theme.spacing(), - }, - root: { - height: '100%', - overflowY: 'scroll', - position: 'relative', - }, - noResultsDiv: { - height: '100%', - width: '100%', - display: 'flex', - justifyContent: 'center', - alignItems: 'center', - }, - loadingGifStyle: { - height: '100%', - width: '100%', - display: 'flex', - justifyContent: 'center', - alignItems: 'center', - }, - spacing: { - height: '50px', - marginBottom: '5px', - }, -}); - -const flattenSOCObject = (SOCObject: WebsocAPIResponse): (WebsocSchool | WebsocDepartment | AACourse)[] => { +function flattenSOCObject(SOCObject: WebsocAPIResponse): (WebsocSchool | WebsocDepartment | AACourse)[] { const courseColors = AppStore.getAddedCourses().reduce((accumulator, { section }) => { accumulator[section.sectionCode] = section.color; return accumulator; @@ -192,28 +135,32 @@ const SectionTableWrapped = ( return
{component}
; }; -interface CourseRenderPaneProps { - classes: ClassNameMap; -} +export function CourseRenderPane() { + const [loading, setLoading] = useState(true); + const [error, setError] = useState(false); + const [scheduleNames, setScheduleNames] = useState(AppStore.getScheduleNames()); + const [courseData, setCourseData] = useState<(WebsocSchool | WebsocDepartment | AACourse)[]>([]); -interface CourseRenderPaneState { - courseData: (WebsocSchool | WebsocDepartment | AACourse)[]; - loading: boolean; - error: boolean; - scheduleNames: string[]; -} + const loadCourses = useCallback(async () => { + setLoading(true); -class CourseRenderPane extends PureComponent { - state: CourseRenderPaneState = { - courseData: [], - loading: true, - error: false, - scheduleNames: AppStore.getScheduleNames(), - }; + const formData = RightPaneStore.getFormData(); - loadCourses = () => { - this.setState({ loading: true }, async () => { - const formData = RightPaneStore.getFormData(); + const params = { + department: formData.deptValue, + term: formData.term, + ge: formData.ge, + courseNumber: formData.courseNumber, + sectionCodes: formData.sectionCode, + instructorName: formData.instructor, + units: formData.units, + endTime: formData.endTime, + startTime: formData.startTime, + fullCourses: formData.coursesFull, + building: formData.building, + room: formData.room, + division: formData.division, + }; const websocQueryParams = { department: formData.deptValue, @@ -245,89 +192,104 @@ class CourseRenderPane extends PureComponent { + loadCourses(); + }, []); - componentWillUnmount() { - AppStore.removeListener('scheduleNamesChange', this.updateScheduleNames); - } + useEffect(() => { + const updateScheduleNames = () => { + setScheduleNames(AppStore.getScheduleNames()); + }; - updateScheduleNames = () => { - this.setState({ scheduleNames: AppStore.getScheduleNames() }); - }; + AppStore.on('scheduleNamesChange', updateScheduleNames); - render() { - const { classes } = this.props; - let currentView; + return () => { + AppStore.off('scheduleNamesChange', updateScheduleNames); + }; + }, []); - if (this.state.loading) { - currentView = ( -
- Loading courses -
- ); - } else if (!this.state.error) { - const renderData = { - courseData: this.state.courseData, - scheduleNames: this.state.scheduleNames, - }; + if (loading) { + return ( +
+ Loading courses +
+ ); + } - currentView = ( - <> - -
-
- {this.state.courseData.length === 0 ? ( -
- No Results Found -
- ) : ( - this.state.courseData.map( - (_: WebsocSchool | WebsocDepartment | AACourse, index: number) => { - let heightEstimate = 200; - if ((this.state.courseData[index] as AACourse).sections !== undefined) - heightEstimate = - (this.state.courseData[index] as AACourse).sections.length * 60 + 20 + 40; + if (error) { + return ( +
+
+ No Results Found +
+
+ ); + } - return ( - - {SectionTableWrapped(index, renderData)} - - ); - } - ) - )} -
- - ); - } else { - currentView = ( -
-
+ return ( + <> + +
+
+ {courseData.length === 0 ? ( +
No Results Found
-
- ); - } + ) : ( + courseData.map((_, index: number) => { + let heightEstimate = 200; + if ((courseData[index] as AACourse).sections !== undefined) + heightEstimate = (courseData[index] as AACourse).sections.length * 60 + 20 + 40; - return currentView; - } + return ( + + {SectionTableWrapped(index, { courseData, scheduleNames })} + + ); + }) + )} +
+ + ); } -export default withStyles(styles)(CourseRenderPane); +export default CourseRenderPane; From 926cda49213ad3b4ee8737b0fcfbe67bcdafda57 Mon Sep 17 00:00:00 2001 From: Minh Nguyen <64875104+MinhxNguyen7@users.noreply.github.com> Date: Tue, 10 Oct 2023 20:57:35 +0000 Subject: [PATCH 89/89] Fix graphql field for queryGrades --- apps/antalmanac/src/lib/grades.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/antalmanac/src/lib/grades.ts b/apps/antalmanac/src/lib/grades.ts index 3508d8702..8e0d5d8e9 100644 --- a/apps/antalmanac/src/lib/grades.ts +++ b/apps/antalmanac/src/lib/grades.ts @@ -143,7 +143,7 @@ class _Grades { if (cacheOnly) return null; const queryString = `{ - aggregateByOffering(department: "${deptCode}", courseNumber: "${courseNumber}", ${instructorFilter}) { + aggregateGrades(department: "${deptCode}", courseNumber: "${courseNumber}", ${instructorFilter}) { gradeDistribution { gradeACount gradeBCount
{instructors.join('\n')}
LocationLocation{locations.length > 1 && 's'} - - {bldg} - + {locations.map((location) => ( +
+ + {location.building} {location.room} + +
+ ))}