diff --git a/src/shared/util/themeColors.ts b/src/shared/util/themeColors.ts index f52cba4c..17571b8f 100644 --- a/src/shared/util/themeColors.ts +++ b/src/shared/util/themeColors.ts @@ -16,6 +16,20 @@ export const colors = { red: '#af2e2d', black: '#1a2024', }, + gradeDistribution: { + a: '#22c55e', + aminus: '#a3e635', + bplus: '#84CC16', + b: '#FDE047', + bminus: '#FACC15', + cplus: '#F59E0B', + c: '#FB923C', + cminus: '#F97316', + dplus: '#EA580C', // TODO (achadaga): copilot generated, get actual color from Isaiah + d: '#DC2626', + dminus: '#B91C1C', + f: '#B91C1C', + }, } as const; type NestedKeys = { diff --git a/src/stories/injected/CourseCatalogInjectedPopup.stories.ts b/src/stories/injected/CourseCatalogInjectedPopup.stories.ts new file mode 100644 index 00000000..91faa2d7 --- /dev/null +++ b/src/stories/injected/CourseCatalogInjectedPopup.stories.ts @@ -0,0 +1,69 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { Course, Status } from 'src/shared/types/Course'; +import { CourseMeeting, DAY_MAP } from 'src/shared/types/CourseMeeting'; +import { CourseSchedule } from 'src/shared/types/CourseSchedule'; +import Instructor from 'src/shared/types/Instructor'; + +import CourseCatalogInjectedPopup from 'src/views/components/injected/CourseCatalogInjectedPopup/CourseCatalogInjectedPopup'; + +const exampleCourse: Course = new Course({ + uniqueId: 50805, + number: '314', + fullName: 'C S 314 DATA STRUCTURES', + courseName: 'DATA STRUCTURES', + department: 'C S', + creditHours: 3, + status: Status.OPEN, + instructors: [ + new Instructor({ fullName: 'SCOTT, MICHAEL', firstName: 'MICHAEL', lastName: 'SCOTT', middleInitial: 'D' }), + ], + isReserved: true, + description: [ + 'Second part of a two-part sequence in programming. Introduction to specifications, simple unit testing, and debugging; building and using canonical data structures; algorithm analysis and reasoning techniques such as assertions and invariants.', + 'Computer Science 314 and 314H may not both be counted.', + 'BVO 311C and 312H may not both be counted.', + 'Prerequisite: Computer Science 312 or 312H with a grade of at least C-.', + 'May be counted toward the Quantitative Reasoning flag requirement.', + ], + schedule: new CourseSchedule({ + meetings: [ + new CourseMeeting({ + days: [DAY_MAP.T, DAY_MAP.TH], + startTime: 480, + endTime: 570, + location: { building: 'UTC', room: '123' }, + }), + new CourseMeeting({ + days: [DAY_MAP.TH], + startTime: 570, + endTime: 630, + location: { building: 'JES', room: '123' }, + }), + ], + }), + url: 'https://utdirect.utexas.edu/apps/registrar/course_schedule/20242/12345/', + flags: ['Writing', 'Independent Inquiry'], + instructionMode: 'In Person', + semester: { + code: '12345', + year: 2024, + season: 'Spring', + }, +}); + +const meta: Meta = { + title: 'Components/Injected/CourseCatalogInjectedPopup', + component: CourseCatalogInjectedPopup, + argTypes: { + onClose: { action: 'onClose' }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + course: exampleCourse, + }, +}; diff --git a/src/stories/injected/CoursePopup.stories.ts b/src/stories/injected/CoursePopup.stories.ts index 3f5b3665..83377319 100644 --- a/src/stories/injected/CoursePopup.stories.ts +++ b/src/stories/injected/CoursePopup.stories.ts @@ -1,7 +1,7 @@ import { Course, Status } from 'src/shared/types/Course'; import { CourseMeeting } from 'src/shared/types/CourseMeeting'; import { UserSchedule } from 'src/shared/types/UserSchedule'; -import CoursePopup from 'src/views/components/injected/CoursePopup/CoursePopup'; +import CoursePopup from 'src/views/components/injected/CoursePopupOld/CoursePopup'; import type { Meta, StoryObj } from '@storybook/react'; import Instructor from 'src/shared/types/Instructor'; diff --git a/src/views/components/CourseCatalogMain.tsx b/src/views/components/CourseCatalogMain.tsx index c5e7e5da..723f5e7b 100644 --- a/src/views/components/CourseCatalogMain.tsx +++ b/src/views/components/CourseCatalogMain.tsx @@ -8,7 +8,7 @@ import { SiteSupport } from '../lib/getSiteSupport'; import { populateSearchInputs } from '../lib/populateSearchInputs'; import ExtensionRoot from './common/ExtensionRoot/ExtensionRoot'; import AutoLoad from './injected/AutoLoad/AutoLoad'; -import CoursePopup from './injected/CoursePopup/CoursePopup'; +import CoursePopup from './injected/CoursePopupOld/CoursePopup'; import RecruitmentBanner from './injected/RecruitmentBanner/RecruitmentBanner'; import TableHead from './injected/TableHead'; import TableRow from './injected/TableRow/TableRow'; diff --git a/src/views/components/common/Chip/Chip.tsx b/src/views/components/common/Chip/Chip.tsx index 90a6009c..b6701517 100644 --- a/src/views/components/common/Chip/Chip.tsx +++ b/src/views/components/common/Chip/Chip.tsx @@ -1,10 +1,21 @@ import React from 'react'; import Text from '../Text/Text'; -export const flags = ['WR', 'QR', 'GC', 'CD', 'E', 'II']; +/** + * A type that represents the flags that a course can have. + */ +export type Flag = 'WR' | 'QR' | 'GC' | 'CD' | 'E' | 'II'; +export const flagMap: Record = { + Writing: 'WR', + 'Quantitative Reasoning': 'QR', + 'Global Cultures': 'GC', + 'Cultural Diversity in the United States': 'CD', + Ethics: 'E', + 'Independent Inquiry': 'II', +}; interface Props { - label: string; + label: Flag; } /** diff --git a/src/views/components/injected/CourseCatalogInjectedPopup/CourseCatalogInjectedPopup.tsx b/src/views/components/injected/CourseCatalogInjectedPopup/CourseCatalogInjectedPopup.tsx new file mode 100644 index 00000000..0dfe7e26 --- /dev/null +++ b/src/views/components/injected/CourseCatalogInjectedPopup/CourseCatalogInjectedPopup.tsx @@ -0,0 +1,24 @@ +import Popup from '@views/components/common/Popup/Popup'; +import React from 'react'; +import { Course } from 'src/shared/types/Course'; +import { UserSchedule } from 'src/shared/types/UserSchedule'; +import Description from './Description'; +import GradeDistribution from './GradeDistribution'; +import HeadingAndActions from './HeadingAndActions'; + +interface CourseCatalogInjectedPopupProps { + course: Course; + activeSchedule?: UserSchedule; + onClose: () => void; +} + +const CourseCatalogInjectedPopup: React.FC = ({ course, activeSchedule, onClose }) => ( + +
+ + + +
+
+); +export default CourseCatalogInjectedPopup; diff --git a/src/views/components/injected/CourseCatalogInjectedPopup/Description.tsx b/src/views/components/injected/CourseCatalogInjectedPopup/Description.tsx new file mode 100644 index 00000000..0d8bb118 --- /dev/null +++ b/src/views/components/injected/CourseCatalogInjectedPopup/Description.tsx @@ -0,0 +1,30 @@ +import clsx from 'clsx'; +import React from 'react'; +import Text from '../../common/Text/Text'; + +interface DescriptionProps { + lines: string[]; +} + +const Description: React.FC = ({ lines }: DescriptionProps) => { + const keywords = ['prerequisite', 'restricted']; + return ( +
    + {lines.map(line => { + const isKeywordPresent = keywords.some(keyword => line.toLowerCase().includes(keyword)); + return ( +
    + +
  • + + {line} + +
  • +
    + ); + })} +
+ ); +}; + +export default Description; diff --git a/src/views/components/injected/CourseCatalogInjectedPopup/GradeDistribution.tsx b/src/views/components/injected/CourseCatalogInjectedPopup/GradeDistribution.tsx new file mode 100644 index 00000000..b1915d37 --- /dev/null +++ b/src/views/components/injected/CourseCatalogInjectedPopup/GradeDistribution.tsx @@ -0,0 +1,170 @@ +import Spinner from '@views/components/common/Spinner/Spinner'; +import Text from '@views/components/common/Text/Text'; +import Highcharts from 'highcharts'; +import HighchartsReact from 'highcharts-react-official'; +import React from 'react'; +import { Course } from 'src/shared/types/Course'; +import { Distribution, LetterGrade } from 'src/shared/types/Distribution'; +import { colors } from 'src/shared/util/themeColors'; +import { + NoDataError, + queryAggregateDistribution, + querySemesterDistribution, +} from 'src/views/lib/database/queryDistribution'; + +interface GradeDistributionProps { + course: Course; +} + +enum DataStatus { + LOADING = 'LOADING', + FOUND = 'FOUND', + NOT_FOUND = 'NOT_FOUND', + ERROR = 'ERROR', +} + +const GRADE_COLORS: Record = { + A: colors.gradeDistribution.a, + 'A-': colors.gradeDistribution.aminus, + 'B+': colors.gradeDistribution.bplus, + B: colors.gradeDistribution.b, + 'B-': colors.gradeDistribution.bminus, + 'C+': colors.gradeDistribution.cplus, + C: colors.gradeDistribution.c, + 'C-': colors.gradeDistribution.cminus, + 'D+': colors.gradeDistribution.dplus, + D: colors.gradeDistribution.d, + 'D-': colors.gradeDistribution.dminus, + F: colors.gradeDistribution.f, +}; + +const GradeDistribution: React.FC = ({ course }) => { + const [semester, setSemester] = React.useState('Aggregate'); + const [distributions, setDistributions] = React.useState>({}); + const [status, setStatus] = React.useState(DataStatus.LOADING); + const ref = React.useRef(null); + + const chartData = React.useMemo(() => { + if (status === DataStatus.FOUND && distributions[semester]) { + return Object.entries(distributions[semester]).map(([grade, count]) => ({ + y: count, + color: GRADE_COLORS[grade as LetterGrade], + })); + } + return []; + }, [distributions, semester, status]); + + React.useEffect(() => { + const fetchInitialData = async () => { + try { + const [aggregateDist, semesters] = await queryAggregateDistribution(course); + const initialDistributions: Record = { Aggregate: aggregateDist }; + const semesterPromises = semesters.map(semester => querySemesterDistribution(course, semester)); + const semesterDistributions = await Promise.all(semesterPromises); + semesters.forEach((semester, i) => { + initialDistributions[`${semester.season} ${semester.year}`] = semesterDistributions[i]; + }); + setDistributions(initialDistributions); + setStatus(DataStatus.FOUND); + } catch (e) { + console.error(e); + if (e instanceof NoDataError) { + setStatus(DataStatus.NOT_FOUND); + } else { + setStatus(DataStatus.ERROR); + } + } + }; + + fetchInitialData(); + }, [course]); + + const handleSelectSemester = (event: React.ChangeEvent) => { + setSemester(event.target.value); + }; + + const chartOptions: Highcharts.Options = { + title: { text: undefined }, + subtitle: { text: undefined }, + legend: { enabled: false }, + xAxis: { + title: { text: 'Grade' }, + categories: ['A', 'A-', 'B+', 'B', 'B-', 'C+', 'C', 'C-', 'D+', 'D', 'D-', 'F'], + crosshair: true, + }, + yAxis: { + min: 0, + title: { text: 'Number of Students' }, + }, + chart: { + style: { fontFamily: 'Roboto Flex', fontWeight: '600' }, + spacingBottom: 25, + spacingTop: 25, + height: 250, + }, + credits: { enabled: false }, + accessibility: { enabled: true }, + tooltip: { + headerFormat: '{point.key}', + pointFormat: + '', + footerFormat: '
{point.y:.0f} Students
', + shared: true, + useHTML: true, + }, + plotOptions: { + bar: { pointPadding: 0.2, borderWidth: 0 }, + series: { animation: { duration: 700 } }, + }, + series: [ + { + type: 'column', + name: 'Grades', + data: chartData, + }, + ], + }; + + return ( +
+ {status === DataStatus.LOADING && } + {status === DataStatus.NOT_FOUND && No grade distribution data found} + {status === DataStatus.ERROR && Error fetching grade distribution data} + {status === DataStatus.FOUND && ( + <> +
+ Grade distribution for {`${course.department} ${course.number}`} + +
+ + + )} +
+ ); +}; + +export default GradeDistribution; diff --git a/src/views/components/injected/CourseCatalogInjectedPopup/HeadingAndActions.tsx b/src/views/components/injected/CourseCatalogInjectedPopup/HeadingAndActions.tsx new file mode 100644 index 00000000..117fcfbe --- /dev/null +++ b/src/views/components/injected/CourseCatalogInjectedPopup/HeadingAndActions.tsx @@ -0,0 +1,136 @@ +import { Button } from '@views/components/common/Button/Button'; +import { Chip, flagMap } from '@views/components/common/Chip/Chip'; +import Divider from '@views/components/common/Divider/Divider'; +import Text from '@views/components/common/Text/Text'; +import React from 'react'; +import addCourse from 'src/pages/background/lib/addCourse'; +import openNewTab from 'src/pages/background/util/openNewTab'; +import { Course } from 'src/shared/types/Course'; +import { UserSchedule } from 'src/shared/types/UserSchedule'; +import Add from '~icons/material-symbols/add'; +import CalendarMonth from '~icons/material-symbols/calendar-month'; +import CloseIcon from '~icons/material-symbols/close'; +import Copy from '~icons/material-symbols/content-copy'; +import Description from '~icons/material-symbols/description'; +import Mood from '~icons/material-symbols/mood'; +import Reviews from '~icons/material-symbols/reviews'; + +interface HeadingAndActionProps { + /* The course to display */ + course: Course; + /* The active schedule */ + activeSchedule: UserSchedule; + /* The function to call when the popup should be closed */ + onClose: () => void; +} + +/** + * Renders the heading component for the CoursePopup component. + * + * @param {HeadingAndActionProps} props - The component props. + * @returns {JSX.Element} The rendered component. + */ +const HeadingAndActions: React.FC = ({ course, onClose, activeSchedule }) => { + const { courseName, department, number: courseNumber, uniqueId, instructors, flags, schedule } = course; + const instructorString = instructors + .map(instructor => { + const { firstName, lastName } = instructor; + if (firstName === '') return lastName; + return `${firstName} ${lastName}`; + }) + .join(', '); + const handleCopy = () => { + navigator.clipboard.writeText(uniqueId.toString()); + }; + const handleOpenCalendar = async () => { + const url = chrome.runtime.getURL('calendar.html'); + await openNewTab(url); + }; + const handleOpenRateMyProf = async () => { + const openTabs = instructors.map(instructor => { + const { fullName } = instructor; + const url = `https://www.ratemyprofessors.com/search/professors/1255?q=${fullName}`; + return openNewTab(url); + }); + await Promise.all(openTabs); + }; + const handleOpenCES = async () => { + // TODO: does not look up the professor just takes you to the page + const cisUrl = 'https://utexas.bluera.com/utexas/rpvl.aspx?rid=d3db767b-049f-46c5-9a67-29c21c29c580®l=en-US'; + await openNewTab(cisUrl); + }; + const handleOpenPastSyllabi = async () => { + // not specific to professor + const url = `https://utdirect.utexas.edu/apps/student/coursedocs/nlogon/?year=&semester=&department=${department}&course_number=${courseNumber}&course_title=${courseName}&unique=&instructor_first=&instructor_last=&course_type=In+Residence&search=Search`; + await openNewTab(url); + }; + const handleAddCourse = async () => { + await addCourse(activeSchedule.name, course); + }; + return ( +
+
+
+ + {courseName} + + + {' '} + ({department} {courseNumber}) + + + +
+
+ + with {instructorString} + +
+ {flags.map(flag => ( + + ))} +
+
+
+ {schedule.meetings.map(meeting => ( + + {meeting.getDaysString({ format: 'long', separator: 'long' })}{' '} + {meeting.getTimeString({ separator: ' to ', capitalize: false })} + {meeting.location && ( + <> + {` in `} + + {meeting.location.building} + + + )} + + ))} +
+
+
+ + + + +
+ +
+ ); +}; + +export default HeadingAndActions; diff --git a/src/views/components/injected/CoursePopup/CourseDescription/CourseDescription.module.scss b/src/views/components/injected/CoursePopupOld/CourseDescription/CourseDescription.module.scss similarity index 100% rename from src/views/components/injected/CoursePopup/CourseDescription/CourseDescription.module.scss rename to src/views/components/injected/CoursePopupOld/CourseDescription/CourseDescription.module.scss diff --git a/src/views/components/injected/CoursePopup/CourseDescription/CourseDescription.tsx b/src/views/components/injected/CoursePopupOld/CourseDescription/CourseDescription.tsx similarity index 100% rename from src/views/components/injected/CoursePopup/CourseDescription/CourseDescription.tsx rename to src/views/components/injected/CoursePopupOld/CourseDescription/CourseDescription.tsx diff --git a/src/views/components/injected/CoursePopup/CourseHeader/CourseButtons/CourseButtons.module.scss b/src/views/components/injected/CoursePopupOld/CourseHeader/CourseButtons/CourseButtons.module.scss similarity index 100% rename from src/views/components/injected/CoursePopup/CourseHeader/CourseButtons/CourseButtons.module.scss rename to src/views/components/injected/CoursePopupOld/CourseHeader/CourseButtons/CourseButtons.module.scss diff --git a/src/views/components/injected/CoursePopup/CourseHeader/CourseButtons/CourseButtons.tsx b/src/views/components/injected/CoursePopupOld/CourseHeader/CourseButtons/CourseButtons.tsx similarity index 100% rename from src/views/components/injected/CoursePopup/CourseHeader/CourseButtons/CourseButtons.tsx rename to src/views/components/injected/CoursePopupOld/CourseHeader/CourseButtons/CourseButtons.tsx diff --git a/src/views/components/injected/CoursePopup/CourseHeader/CourseHeader.module.scss b/src/views/components/injected/CoursePopupOld/CourseHeader/CourseHeader.module.scss similarity index 100% rename from src/views/components/injected/CoursePopup/CourseHeader/CourseHeader.module.scss rename to src/views/components/injected/CoursePopupOld/CourseHeader/CourseHeader.module.scss diff --git a/src/views/components/injected/CoursePopup/CourseHeader/CourseHeader.tsx b/src/views/components/injected/CoursePopupOld/CourseHeader/CourseHeader.tsx similarity index 100% rename from src/views/components/injected/CoursePopup/CourseHeader/CourseHeader.tsx rename to src/views/components/injected/CoursePopupOld/CourseHeader/CourseHeader.tsx diff --git a/src/views/components/injected/CoursePopup/CoursePopup.module.scss b/src/views/components/injected/CoursePopupOld/CoursePopup.module.scss similarity index 100% rename from src/views/components/injected/CoursePopup/CoursePopup.module.scss rename to src/views/components/injected/CoursePopupOld/CoursePopup.module.scss diff --git a/src/views/components/injected/CoursePopup/CoursePopup.tsx b/src/views/components/injected/CoursePopupOld/CoursePopup.tsx similarity index 100% rename from src/views/components/injected/CoursePopup/CoursePopup.tsx rename to src/views/components/injected/CoursePopupOld/CoursePopup.tsx diff --git a/src/views/components/injected/CoursePopup/GradeDistribution/GradeDistribution.module.scss b/src/views/components/injected/CoursePopupOld/GradeDistribution/GradeDistribution.module.scss similarity index 100% rename from src/views/components/injected/CoursePopup/GradeDistribution/GradeDistribution.module.scss rename to src/views/components/injected/CoursePopupOld/GradeDistribution/GradeDistribution.module.scss diff --git a/src/views/components/injected/CoursePopup/GradeDistribution/GradeDistribution.tsx b/src/views/components/injected/CoursePopupOld/GradeDistribution/GradeDistribution.tsx similarity index 100% rename from src/views/components/injected/CoursePopup/GradeDistribution/GradeDistribution.tsx rename to src/views/components/injected/CoursePopupOld/GradeDistribution/GradeDistribution.tsx