From eed8eff6cd09462eaa5fedda326f73310781f872 Mon Sep 17 00:00:00 2001 From: bruhkookie Date: Mon, 6 Apr 2026 02:23:26 -0700 Subject: [PATCH 01/22] visualization method --- .../RightPane/SectionTable/SearchFilter.tsx | 78 +++++++++++++++++++ .../RightPane/SectionTable/SectionTable.tsx | 2 + 2 files changed, 80 insertions(+) create mode 100644 apps/antalmanac/src/components/RightPane/SectionTable/SearchFilter.tsx diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/SearchFilter.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/SearchFilter.tsx new file mode 100644 index 0000000000..14d16013a7 --- /dev/null +++ b/apps/antalmanac/src/components/RightPane/SectionTable/SearchFilter.tsx @@ -0,0 +1,78 @@ +import { MenuItem, type SelectChangeEvent } from '@mui/material'; +import { useEffect, useCallback, useState } from 'react'; + +import { LabeledSelect } from '$components/RightPane/CoursePane/SearchForm/LabeledInputs/LabeledSelect'; +import RightPaneStore from '$components/RightPane/RightPaneStore'; + +const GE_LIST = [ + { value: 'ANY', label: "All: Don't filter for GE" }, + { value: 'GE-1A', label: 'GE Ia (1a): Lower Division Writing' }, + { value: 'GE-1B', label: 'GE Ib (1b): Upper Division Writing' }, + { value: 'GE-2', label: 'GE II (2): Science and Technology' }, + { value: 'GE-3', label: 'GE III (3): Social and Behavioral Sciences' }, + { value: 'GE-4', label: 'GE IV (4): Arts and Humanities' }, + { value: 'GE-5A', label: 'GE Va (5a): Quantitative Literacy' }, + { value: 'GE-5B', label: 'GE Vb (5b): Formal Reasoning' }, + { value: 'GE-6', label: 'GE VI (6): Language other than English' }, + { value: 'GE-7', label: 'GE VII (7): Multicultural Studies' }, + { value: 'GE-8', label: 'GE VIII (8): International/Global Issues' }, +] as const; + +export function SearchFilter() { + const [ge, setGe] = useState(() => RightPaneStore.getFormData().ge); + + const handleChange = (event: SelectChangeEvent) => { + const value = event.target.value; + + setGe(value); + RightPaneStore.updateFormValue('ge', value); + + const stateObj = { url: 'url' }; + const url = new URL(window.location.href); + const urlParam = new URLSearchParams(url.search); + + urlParam.delete('ge'); + + if (value !== 'ANY') { + urlParam.append('ge', value); + } + + const param = urlParam.toString(); + const new_url = `${param.trim() ? '?' : ''}${param}`; + history.replaceState(stateObj, 'url', '/' + new_url); + }; + + const resetField = useCallback(() => { + setGe(RightPaneStore.getFormData().ge); + }, []); + + useEffect(() => { + RightPaneStore.on('formReset', resetField); + + return () => { + RightPaneStore.off('formReset', resetField); + }; + }, [resetField]); + + return ( + + {GE_LIST.map((category) => { + return ( + + {category.label} + + ); + })} + + ); +} diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTable.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTable.tsx index 5c415f891a..3a4892a6a6 100644 --- a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTable.tsx +++ b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTable.tsx @@ -8,6 +8,7 @@ import { CourseInfoSearchButton } from '$components/RightPane/SectionTable/Cours import { EnrollmentColumnHeader } from '$components/RightPane/SectionTable/EnrollmentColumnHeader'; import { EnrollmentHistoryPopup } from '$components/RightPane/SectionTable/EnrollmentHistoryPopup'; import GradesPopup from '$components/RightPane/SectionTable/GradesPopup'; +import { SearchFilter } from '$components/RightPane/SectionTable/SearchFilter'; import { SectionTableProps } from '$components/RightPane/SectionTable/SectionTable.types'; import { SectionTableBody } from '$components/RightPane/SectionTable/SectionTableBody/SectionTableBody'; import { useIsMobile } from '$hooks/useIsMobile'; @@ -154,6 +155,7 @@ function SectionTable(props: SectionTableProps) { /> } /> + {missingSections?.length > 0 && ( From d0c5976b06218bc982fba7af0a11a1a818db9e4e Mon Sep 17 00:00:00 2001 From: bruhkookie Date: Mon, 6 Apr 2026 02:39:31 -0700 Subject: [PATCH 02/22] add mock filters / placeholders --- .../RightPane/SectionTable/SearchFilter.tsx | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/SearchFilter.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/SearchFilter.tsx index 14d16013a7..9fa9c96507 100644 --- a/apps/antalmanac/src/components/RightPane/SectionTable/SearchFilter.tsx +++ b/apps/antalmanac/src/components/RightPane/SectionTable/SearchFilter.tsx @@ -5,17 +5,10 @@ import { LabeledSelect } from '$components/RightPane/CoursePane/SearchForm/Label import RightPaneStore from '$components/RightPane/RightPaneStore'; const GE_LIST = [ - { value: 'ANY', label: "All: Don't filter for GE" }, - { value: 'GE-1A', label: 'GE Ia (1a): Lower Division Writing' }, - { value: 'GE-1B', label: 'GE Ib (1b): Upper Division Writing' }, - { value: 'GE-2', label: 'GE II (2): Science and Technology' }, - { value: 'GE-3', label: 'GE III (3): Social and Behavioral Sciences' }, - { value: 'GE-4', label: 'GE IV (4): Arts and Humanities' }, - { value: 'GE-5A', label: 'GE Va (5a): Quantitative Literacy' }, - { value: 'GE-5B', label: 'GE Vb (5b): Formal Reasoning' }, - { value: 'GE-6', label: 'GE VI (6): Language other than English' }, - { value: 'GE-7', label: 'GE VII (7): Multicultural Studies' }, - { value: 'GE-8', label: 'GE VIII (8): International/Global Issues' }, + { value: 'ANY', label: 'Status' }, + { value: 'GE-1A', label: 'Time' }, + { value: 'GE-1B', label: 'Date: MWF' }, + { value: 'GE-2', label: 'Date: TuTh' }, ] as const; export function SearchFilter() { From 36d98a81910d75fc625532c80c82938761a1b140 Mon Sep 17 00:00:00 2001 From: bruhkookie Date: Mon, 6 Apr 2026 02:44:11 -0700 Subject: [PATCH 03/22] add clear and delete logic --- .../RightPane/SectionTable/SearchFilter.tsx | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/SearchFilter.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/SearchFilter.tsx index 9fa9c96507..c41a62a463 100644 --- a/apps/antalmanac/src/components/RightPane/SectionTable/SearchFilter.tsx +++ b/apps/antalmanac/src/components/RightPane/SectionTable/SearchFilter.tsx @@ -5,6 +5,7 @@ import { LabeledSelect } from '$components/RightPane/CoursePane/SearchForm/Label import RightPaneStore from '$components/RightPane/RightPaneStore'; const GE_LIST = [ + { value: 'ANY', label: 'Clear' }, { value: 'ANY', label: 'Status' }, { value: 'GE-1A', label: 'Time' }, { value: 'GE-1B', label: 'Date: MWF' }, @@ -19,20 +20,6 @@ export function SearchFilter() { setGe(value); RightPaneStore.updateFormValue('ge', value); - - const stateObj = { url: 'url' }; - const url = new URL(window.location.href); - const urlParam = new URLSearchParams(url.search); - - urlParam.delete('ge'); - - if (value !== 'ANY') { - urlParam.append('ge', value); - } - - const param = urlParam.toString(); - const new_url = `${param.trim() ? '?' : ''}${param}`; - history.replaceState(stateObj, 'url', '/' + new_url); }; const resetField = useCallback(() => { From 4399bf9c54f4ba4818f28cb4dcd26773cb11928a Mon Sep 17 00:00:00 2001 From: bruhkookie Date: Mon, 6 Apr 2026 02:57:16 -0700 Subject: [PATCH 04/22] add search filter logic --- .../RightPane/SectionTable/SearchFilter.tsx | 46 ++++--------------- 1 file changed, 10 insertions(+), 36 deletions(-) diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/SearchFilter.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/SearchFilter.tsx index c41a62a463..57ae0b3c8b 100644 --- a/apps/antalmanac/src/components/RightPane/SectionTable/SearchFilter.tsx +++ b/apps/antalmanac/src/components/RightPane/SectionTable/SearchFilter.tsx @@ -1,44 +1,20 @@ import { MenuItem, type SelectChangeEvent } from '@mui/material'; -import { useEffect, useCallback, useState } from 'react'; import { LabeledSelect } from '$components/RightPane/CoursePane/SearchForm/LabeledInputs/LabeledSelect'; -import RightPaneStore from '$components/RightPane/RightPaneStore'; - -const GE_LIST = [ - { value: 'ANY', label: 'Clear' }, - { value: 'ANY', label: 'Status' }, - { value: 'GE-1A', label: 'Time' }, - { value: 'GE-1B', label: 'Date: MWF' }, - { value: 'GE-2', label: 'Date: TuTh' }, -] as const; +import { SORT_OPTIONS, useSectionFilterStore, type SortOption } from '$stores/SectionFilterStore'; export function SearchFilter() { - const [ge, setGe] = useState(() => RightPaneStore.getFormData().ge); + const { sortBy, setSortBy } = useSectionFilterStore(); const handleChange = (event: SelectChangeEvent) => { - const value = event.target.value; - - setGe(value); - RightPaneStore.updateFormValue('ge', value); + setSortBy(event.target.value as SortOption); }; - const resetField = useCallback(() => { - setGe(RightPaneStore.getFormData().ge); - }, []); - - useEffect(() => { - RightPaneStore.on('formReset', resetField); - - return () => { - RightPaneStore.off('formReset', resetField); - }; - }, [resetField]); - return ( - {GE_LIST.map((category) => { - return ( - - {category.label} - - ); - })} + {SORT_OPTIONS.map((option) => ( + + {option.label} + + ))} ); } From c127008fd92bb8ff39302d798852a4ce3e785d2b Mon Sep 17 00:00:00 2001 From: bruhkookie Date: Mon, 6 Apr 2026 13:42:11 -0700 Subject: [PATCH 05/22] add working test scripts --- .../RightPane/SectionTable/SearchFilter.tsx | 38 ++++++------ .../SectionTableBody/SectionTableBody.tsx | 60 ++++++++++++++++++- .../src/stores/SectionFilterStore.ts | 22 +++++++ 3 files changed, 99 insertions(+), 21 deletions(-) create mode 100644 apps/antalmanac/src/stores/SectionFilterStore.ts diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/SearchFilter.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/SearchFilter.tsx index 57ae0b3c8b..c88dde8310 100644 --- a/apps/antalmanac/src/components/RightPane/SectionTable/SearchFilter.tsx +++ b/apps/antalmanac/src/components/RightPane/SectionTable/SearchFilter.tsx @@ -1,9 +1,10 @@ -import { MenuItem, type SelectChangeEvent } from '@mui/material'; +import { FormControl, InputLabel, MenuItem, Select, type SelectChangeEvent } from '@mui/material'; +import { useId } from 'react'; -import { LabeledSelect } from '$components/RightPane/CoursePane/SearchForm/LabeledInputs/LabeledSelect'; import { SORT_OPTIONS, useSectionFilterStore, type SortOption } from '$stores/SectionFilterStore'; export function SearchFilter() { + const id = useId(); const { sortBy, setSortBy } = useSectionFilterStore(); const handleChange = (event: SelectChangeEvent) => { @@ -11,22 +12,21 @@ export function SearchFilter() { }; return ( - - {SORT_OPTIONS.map((option) => ( - - {option.label} - - ))} - + + Sort By + + ); } diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody/SectionTableBody.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody/SectionTableBody.tsx index 3855f98847..8c183a6441 100644 --- a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody/SectionTableBody.tsx +++ b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody/SectionTableBody.tsx @@ -1,12 +1,62 @@ import { TableBody } from '@mui/material'; import { AACourse, AASection } from '@packages/antalmanac-types'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { SectionTableBodyRow } from '$components/RightPane/SectionTable/SectionTableBody/SectionTableBodyRow'; import { AnalyticsCategory } from '$lib/analytics/analytics'; import AppStore from '$stores/AppStore'; +import { useSectionFilterStore, type SortOption } from '$stores/SectionFilterStore'; import { normalizeTime, parseDaysString } from '$stores/calendarizeHelpers'; +function getMeetingStartMinutes(section: AASection): number { + const meeting = section.meetings[0]; + if (!meeting || meeting.timeIsTBA) return Infinity; + return meeting.startTime.hour * 60 + meeting.startTime.minute; +} + +function getMeetingDays(section: AASection): string { + const meeting = section.meetings[0]; + if (!meeting || meeting.timeIsTBA) return ''; + return meeting.days; +} + +const STATUS_ORDER: Record = { OPEN: 0, NewOnly: 1, Waitl: 2, FULL: 3, '': 4 }; + +function sortSections(sections: AASection[], sortBy: SortOption): AASection[] { + if (sortBy === 'default') return sections; + + return [...sections].sort((a, b) => { + switch (sortBy) { + case 'status': + return (STATUS_ORDER[a.status] ?? 4) - (STATUS_ORDER[b.status] ?? 4); + + case 'time_asc': + return getMeetingStartMinutes(a) - getMeetingStartMinutes(b); + + case 'days_mwf': { + const aMatch = /[MWF]/.test(getMeetingDays(a)) ? 0 : 1; + const bMatch = /[MWF]/.test(getMeetingDays(b)) ? 0 : 1; + return aMatch - bMatch; + } + + case 'days_tuth': { + const aMatch = /Tu|Th/.test(getMeetingDays(a)) ? 0 : 1; + const bMatch = /Tu|Th/.test(getMeetingDays(b)) ? 0 : 1; + return aMatch - bMatch; + } + + case 'enrollment': { + const aRatio = parseInt(a.numCurrentlyEnrolled.totalEnrolled) / (parseInt(a.maxCapacity) || 1); + const bRatio = parseInt(b.numCurrentlyEnrolled.totalEnrolled) / (parseInt(b.maxCapacity) || 1); + return aRatio - bRatio; + } + + default: + return 0; + } + }); +} + interface SectionTableBodyProps { courseDetails: AACourse; term: string; @@ -24,6 +74,7 @@ export function SectionTableBody({ analyticsCategory, formattedTime, }: SectionTableBodyProps) { + const { sortBy } = useSectionFilterStore(); const [calendarEvents, setCalendarEvents] = useState(() => AppStore.getCourseEventsInCalendar()); /** @@ -82,9 +133,14 @@ export function SectionTableBody({ }; }, [updateCalendarEvents]); + const sortedSections = useMemo( + () => sortSections(courseDetails.sections, sortBy), + [courseDetails.sections, sortBy] + ); + return ( - {courseDetails.sections.map((section) => { + {sortedSections.map((section) => { const conflict = scheduleConflict(section); return ( diff --git a/apps/antalmanac/src/stores/SectionFilterStore.ts b/apps/antalmanac/src/stores/SectionFilterStore.ts new file mode 100644 index 0000000000..fac26889ba --- /dev/null +++ b/apps/antalmanac/src/stores/SectionFilterStore.ts @@ -0,0 +1,22 @@ +import { create } from 'zustand'; + +export const SORT_OPTIONS = [ + { value: 'default', label: 'Default' }, + { value: 'status', label: 'Status' }, + { value: 'time_asc', label: 'Time' }, + { value: 'days_mwf', label: 'Date: MWF' }, + { value: 'days_tuth', label: 'Date: TuTh' }, + { value: 'enrollment', label: 'Enrollment' }, +] as const; + +export type SortOption = (typeof SORT_OPTIONS)[number]['value']; + +interface SectionFilterStore { + sortBy: SortOption; + setSortBy: (option: SortOption) => void; +} + +export const useSectionFilterStore = create((set) => ({ + sortBy: 'default', + setSortBy: (sortBy) => set({ sortBy }), +})); From 63d6250b55ac12e38da0bb71552a1956a0685051 Mon Sep 17 00:00:00 2001 From: bruhkookie Date: Mon, 6 Apr 2026 13:45:01 -0700 Subject: [PATCH 06/22] adjust sizing --- .../src/components/RightPane/SectionTable/SearchFilter.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/SearchFilter.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/SearchFilter.tsx index c88dde8310..ae8a722c03 100644 --- a/apps/antalmanac/src/components/RightPane/SectionTable/SearchFilter.tsx +++ b/apps/antalmanac/src/components/RightPane/SectionTable/SearchFilter.tsx @@ -19,7 +19,7 @@ export function SearchFilter() { value={sortBy} label="Sort By" onChange={handleChange} - sx={{ height: 32, fontSize: '0.85rem' }} + sx={{ height: 12, fontSize: '0.55rem' }} > {SORT_OPTIONS.map((option) => ( From d26ea78e8f51179510c6675b7d8595d439451ab2 Mon Sep 17 00:00:00 2001 From: bruhkookie Date: Tue, 7 Apr 2026 15:53:06 -0700 Subject: [PATCH 07/22] change styling --- .../RightPane/SectionTable/SearchFilter.tsx | 65 +++++++++++++------ .../RightPane/SectionTable/SectionTable.tsx | 1 + 2 files changed, 47 insertions(+), 19 deletions(-) diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/SearchFilter.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/SearchFilter.tsx index ae8a722c03..019fca10e4 100644 --- a/apps/antalmanac/src/components/RightPane/SectionTable/SearchFilter.tsx +++ b/apps/antalmanac/src/components/RightPane/SectionTable/SearchFilter.tsx @@ -1,10 +1,8 @@ -import { FormControl, InputLabel, MenuItem, Select, type SelectChangeEvent } from '@mui/material'; -import { useId } from 'react'; +import { MenuItem, Select, type SelectChangeEvent } from '@mui/material'; import { SORT_OPTIONS, useSectionFilterStore, type SortOption } from '$stores/SectionFilterStore'; export function SearchFilter() { - const id = useId(); const { sortBy, setSortBy } = useSectionFilterStore(); const handleChange = (event: SelectChangeEvent) => { @@ -12,21 +10,50 @@ export function SearchFilter() { }; return ( - - Sort By - - + ); } diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTable.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTable.tsx index 3a4892a6a6..8c8a059874 100644 --- a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTable.tsx +++ b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTable.tsx @@ -106,6 +106,7 @@ function SectionTable(props: SectionTableProps) { Date: Tue, 7 Apr 2026 15:58:36 -0700 Subject: [PATCH 08/22] adjust size of the filter button --- .../src/components/RightPane/SectionTable/SearchFilter.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/SearchFilter.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/SearchFilter.tsx index 019fca10e4..2816cc165b 100644 --- a/apps/antalmanac/src/components/RightPane/SectionTable/SearchFilter.tsx +++ b/apps/antalmanac/src/components/RightPane/SectionTable/SearchFilter.tsx @@ -21,7 +21,7 @@ export function SearchFilter() { return `Sort: ${option?.label ?? ''}`; }} sx={(theme) => ({ - height: '25.75px', + height: '26px', fontSize: '0.8125rem', fontWeight: 500, textTransform: 'none', From 62a787cc8d48a721d472781f9b0ad58932115615 Mon Sep 17 00:00:00 2001 From: bruhkookie Date: Tue, 7 Apr 2026 16:04:26 -0700 Subject: [PATCH 09/22] display sort filter as all uppercase --- .../src/components/RightPane/SectionTable/SearchFilter.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/SearchFilter.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/SearchFilter.tsx index 2816cc165b..99232b13f9 100644 --- a/apps/antalmanac/src/components/RightPane/SectionTable/SearchFilter.tsx +++ b/apps/antalmanac/src/components/RightPane/SectionTable/SearchFilter.tsx @@ -18,7 +18,7 @@ export function SearchFilter() { displayEmpty renderValue={(value) => { const option = SORT_OPTIONS.find((o) => o.value === value); - return `Sort: ${option?.label ?? ''}`; + return `SORT: ${option?.label.toUpperCase() ?? ''}`; }} sx={(theme) => ({ height: '26px', From 6fa9bab3db2cd2e23fc09d9e7841d38fb966a55c Mon Sep 17 00:00:00 2001 From: bruhkookie Date: Tue, 14 Apr 2026 00:46:21 -0700 Subject: [PATCH 10/22] move search filter to course pane button row --- .../src/components/RightPane/CoursePane/CoursePaneButtonRow.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/antalmanac/src/components/RightPane/CoursePane/CoursePaneButtonRow.tsx b/apps/antalmanac/src/components/RightPane/CoursePane/CoursePaneButtonRow.tsx index d12c9c2966..b3d185baf4 100644 --- a/apps/antalmanac/src/components/RightPane/CoursePane/CoursePaneButtonRow.tsx +++ b/apps/antalmanac/src/components/RightPane/CoursePane/CoursePaneButtonRow.tsx @@ -16,6 +16,7 @@ import { usePostHog } from 'posthog-js/react'; import { useCallback, useMemo, useState } from 'react'; import { NotificationsDialog } from '$components/RightPane/AddedCourses/Notifications/NotificationsDialog'; +import { SearchFilter } from '$components/RightPane/SectionTable/SearchFilter'; import analyticsEnum, { logAnalytics } from '$lib/analytics/analytics'; import { useColumnStore, SECTION_TABLE_COLUMNS, type SectionTableColumn } from '$stores/ColumnStore'; @@ -180,6 +181,7 @@ export function CoursePaneButtonRow(props: CoursePaneButtonRowProps) { + ); } From 56ef37cde422f59957f6865490fe95fb3aef3838 Mon Sep 17 00:00:00 2001 From: bruhkookie Date: Tue, 14 Apr 2026 00:47:05 -0700 Subject: [PATCH 11/22] remove from sectiontable row --- .../src/components/RightPane/SectionTable/SectionTable.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTable.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTable.tsx index 8c8a059874..c23a9de2ff 100644 --- a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTable.tsx +++ b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTable.tsx @@ -8,7 +8,6 @@ import { CourseInfoSearchButton } from '$components/RightPane/SectionTable/Cours import { EnrollmentColumnHeader } from '$components/RightPane/SectionTable/EnrollmentColumnHeader'; import { EnrollmentHistoryPopup } from '$components/RightPane/SectionTable/EnrollmentHistoryPopup'; import GradesPopup from '$components/RightPane/SectionTable/GradesPopup'; -import { SearchFilter } from '$components/RightPane/SectionTable/SearchFilter'; import { SectionTableProps } from '$components/RightPane/SectionTable/SectionTable.types'; import { SectionTableBody } from '$components/RightPane/SectionTable/SectionTableBody/SectionTableBody'; import { useIsMobile } from '$hooks/useIsMobile'; @@ -156,7 +155,6 @@ function SectionTable(props: SectionTableProps) { /> } /> - {missingSections?.length > 0 && ( From 12507c5a97beb5dd35cc337dc938add6bab77fe5 Mon Sep 17 00:00:00 2001 From: bruhkookie Date: Tue, 14 Apr 2026 01:02:33 -0700 Subject: [PATCH 12/22] consistent styling --- .../CoursePane/CoursePaneButtonRow.tsx | 2 +- .../RightPane/SectionTable/SearchFilter.tsx | 96 +++++++++---------- 2 files changed, 47 insertions(+), 51 deletions(-) diff --git a/apps/antalmanac/src/components/RightPane/CoursePane/CoursePaneButtonRow.tsx b/apps/antalmanac/src/components/RightPane/CoursePane/CoursePaneButtonRow.tsx index b3d185baf4..215c8b055c 100644 --- a/apps/antalmanac/src/components/RightPane/CoursePane/CoursePaneButtonRow.tsx +++ b/apps/antalmanac/src/components/RightPane/CoursePane/CoursePaneButtonRow.tsx @@ -181,7 +181,7 @@ export function CoursePaneButtonRow(props: CoursePaneButtonRowProps) { - + ); } diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/SearchFilter.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/SearchFilter.tsx index 99232b13f9..121cf3ee97 100644 --- a/apps/antalmanac/src/components/RightPane/SectionTable/SearchFilter.tsx +++ b/apps/antalmanac/src/components/RightPane/SectionTable/SearchFilter.tsx @@ -1,59 +1,55 @@ -import { MenuItem, Select, type SelectChangeEvent } from '@mui/material'; +import { Sort } from '@mui/icons-material'; +import { IconButton, Menu, MenuItem, Tooltip, type SxProps } from '@mui/material'; +import { useCallback, useState } from 'react'; import { SORT_OPTIONS, useSectionFilterStore, type SortOption } from '$stores/SectionFilterStore'; -export function SearchFilter() { +interface SearchFilterProps { + buttonSx?: SxProps; +} + +export function SearchFilter({ buttonSx }: SearchFilterProps) { const { sortBy, setSortBy } = useSectionFilterStore(); + const [anchorEl, setAnchorEl] = useState(); + const open = Boolean(anchorEl); - const handleChange = (event: SelectChangeEvent) => { - setSortBy(event.target.value as SortOption); - }; + const handleClick = useCallback((event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }, []); + + const handleClose = useCallback(() => { + setAnchorEl(undefined); + }, []); + + const handleSelect = useCallback( + (value: SortOption) => { + setSortBy(value); + handleClose(); + }, + [setSortBy, handleClose] + ); + + const currentLabel = SORT_OPTIONS.find((o) => o.value === sortBy)?.label ?? 'Default'; return ( - + <> + + + + + + + + {SORT_OPTIONS.map((option) => ( + handleSelect(option.value)} + > + {option.label} + + ))} + + ); } From d5f633ad184067ae115af4ea953f34d89be6e046 Mon Sep 17 00:00:00 2001 From: bruhkookie Date: Tue, 14 Apr 2026 14:20:19 -0700 Subject: [PATCH 13/22] remove enrollment filter --- .../SectionTable/SectionTableBody/SectionTableBody.tsx | 7 ------- 1 file changed, 7 deletions(-) diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody/SectionTableBody.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody/SectionTableBody.tsx index 8c183a6441..86f22cfc5b 100644 --- a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody/SectionTableBody.tsx +++ b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody/SectionTableBody.tsx @@ -44,13 +44,6 @@ function sortSections(sections: AASection[], sortBy: SortOption): AASection[] { const bMatch = /Tu|Th/.test(getMeetingDays(b)) ? 0 : 1; return aMatch - bMatch; } - - case 'enrollment': { - const aRatio = parseInt(a.numCurrentlyEnrolled.totalEnrolled) / (parseInt(a.maxCapacity) || 1); - const bRatio = parseInt(b.numCurrentlyEnrolled.totalEnrolled) / (parseInt(b.maxCapacity) || 1); - return aRatio - bRatio; - } - default: return 0; } From 80c500402cb8269a7d2feb7f5ef39ef16379d969 Mon Sep 17 00:00:00 2001 From: bruhkookie Date: Tue, 14 Apr 2026 14:21:31 -0700 Subject: [PATCH 14/22] update search options --- apps/antalmanac/src/stores/SectionFilterStore.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/antalmanac/src/stores/SectionFilterStore.ts b/apps/antalmanac/src/stores/SectionFilterStore.ts index fac26889ba..5134473229 100644 --- a/apps/antalmanac/src/stores/SectionFilterStore.ts +++ b/apps/antalmanac/src/stores/SectionFilterStore.ts @@ -6,7 +6,6 @@ export const SORT_OPTIONS = [ { value: 'time_asc', label: 'Time' }, { value: 'days_mwf', label: 'Date: MWF' }, { value: 'days_tuth', label: 'Date: TuTh' }, - { value: 'enrollment', label: 'Enrollment' }, ] as const; export type SortOption = (typeof SORT_OPTIONS)[number]['value']; From 9a272224dcd908d80c561a8974613c6d8afc0d00 Mon Sep 17 00:00:00 2001 From: bruhkookie Date: Tue, 21 Apr 2026 00:17:40 -0700 Subject: [PATCH 15/22] feat: search by gpa --- .../SectionTableBody/SectionTableBody.tsx | 51 ++++++++++++++++--- .../SectionTableBodyCells/GpaCell.tsx | 26 +++------- .../SectionTableBody/SectionTableBodyRow.tsx | 6 +++ .../src/stores/SectionFilterStore.ts | 1 + 4 files changed, 59 insertions(+), 25 deletions(-) diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody/SectionTableBody.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody/SectionTableBody.tsx index 86f22cfc5b..f466267973 100644 --- a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody/SectionTableBody.tsx +++ b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody/SectionTableBody.tsx @@ -1,13 +1,17 @@ import { TableBody } from '@mui/material'; import { AACourse, AASection } from '@packages/antalmanac-types'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useState, useMemo } from 'react'; +import { getGpaData } from '$components/RightPane/SectionTable/SectionTableBody/SectionTableBodyCells/GpaCell'; import { SectionTableBodyRow } from '$components/RightPane/SectionTable/SectionTableBody/SectionTableBodyRow'; import { AnalyticsCategory } from '$lib/analytics/analytics'; import AppStore from '$stores/AppStore'; -import { useSectionFilterStore, type SortOption } from '$stores/SectionFilterStore'; +import { type SortOption, useSectionFilterStore } from '$stores/SectionFilterStore'; import { normalizeTime, parseDaysString } from '$stores/calendarizeHelpers'; +export type GpaEntry = { gpa: string; instructor: string }; +export type GpaMap = Map; + function getMeetingStartMinutes(section: AASection): number { const meeting = section.meetings[0]; if (!meeting || meeting.timeIsTBA) return Infinity; @@ -22,7 +26,7 @@ function getMeetingDays(section: AASection): string { const STATUS_ORDER: Record = { OPEN: 0, NewOnly: 1, Waitl: 2, FULL: 3, '': 4 }; -function sortSections(sections: AASection[], sortBy: SortOption): AASection[] { +function sortSections(sections: AASection[], sortBy: SortOption, gpaMap: GpaMap): AASection[] { if (sortBy === 'default') return sections; return [...sections].sort((a, b) => { @@ -44,6 +48,15 @@ function sortSections(sections: AASection[], sortBy: SortOption): AASection[] { const bMatch = /Tu|Th/.test(getMeetingDays(b)) ? 0 : 1; return aMatch - bMatch; } + + case 'gpa_descending': { + const aGpa = parseFloat(gpaMap.get(a.sectionCode)?.gpa ?? ''); + const bGpa = parseFloat(gpaMap.get(b.sectionCode)?.gpa ?? ''); + const aValue = Number.isNaN(aGpa) ? -Infinity : aGpa; + const bValue = Number.isNaN(bGpa) ? -Infinity : bGpa; + return bValue - aValue; + } + default: return 0; } @@ -67,8 +80,31 @@ export function SectionTableBody({ analyticsCategory, formattedTime, }: SectionTableBodyProps) { - const { sortBy } = useSectionFilterStore(); const [calendarEvents, setCalendarEvents] = useState(() => AppStore.getCourseEventsInCalendar()); + const [gpaMap, setGpaMap] = useState(() => new Map()); + const sortBy = useSectionFilterStore((state) => state.sortBy); + + useEffect(() => { + let cancelled = false; + + Promise.all( + courseDetails.sections.map(async (section) => { + const data = await getGpaData(courseDetails.deptCode, courseDetails.courseNumber, section.instructors); + return [section.sectionCode, data] as const; + }) + ).then((entries) => { + if (cancelled) return; + const next: GpaMap = new Map(); + for (const [code, data] of entries) { + if (data) next.set(code, data); + } + setGpaMap(next); + }); + + return () => { + cancelled = true; + }; + }, [courseDetails]); /** * Additional information about the current section being rendered. @@ -127,14 +163,15 @@ export function SectionTableBody({ }, [updateCalendarEvents]); const sortedSections = useMemo( - () => sortSections(courseDetails.sections, sortBy), - [courseDetails.sections, sortBy] + () => sortSections(courseDetails.sections, sortBy, gpaMap), + [courseDetails.sections, sortBy, gpaMap] ); return ( {sortedSections.map((section) => { const conflict = scheduleConflict(section); + const gpaEntry = gpaMap.get(section.sectionCode); return ( ); })} diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody/SectionTableBodyCells/GpaCell.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody/SectionTableBodyCells/GpaCell.tsx index 5827d0da93..f2636b51c2 100644 --- a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody/SectionTableBodyCells/GpaCell.tsx +++ b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody/SectionTableBodyCells/GpaCell.tsx @@ -1,5 +1,5 @@ import { Button, Popover } from '@mui/material'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useState } from 'react'; import GradesPopup from '$components/RightPane/SectionTable/GradesPopup'; import { TableBodyCellContainer } from '$components/RightPane/SectionTable/SectionTableBody/SectionTableBodyCells/TableBodyCellContainer'; @@ -7,7 +7,7 @@ import { useIsMobile } from '$hooks/useIsMobile'; import { useSecondaryColor } from '$hooks/useSecondaryColor'; import { Grades } from '$lib/grades'; -async function getGpaData(deptCode: string, courseNumber: string, instructors: string[]) { +export 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 @@ -27,15 +27,14 @@ async function getGpaData(deptCode: string, courseNumber: string, instructors: s interface GpaCellProps { deptCode: string; courseNumber: string; - instructors: string[]; + gpa?: string; + gpaInstructor?: string; } -export const GpaCell = ({ deptCode, courseNumber, instructors }: GpaCellProps) => { +export const GpaCell = ({ deptCode, courseNumber, gpa, gpaInstructor }: GpaCellProps) => { const isMobile = useIsMobile(); const secondaryColor = useSecondaryColor(); - const [gpa, setGpa] = useState(''); - const [instructor, setInstructor] = useState(''); const [anchorEl, setAnchorEl] = useState(); const handleClick = useCallback((event: React.MouseEvent) => { @@ -46,17 +45,6 @@ export const GpaCell = ({ deptCode, courseNumber, instructors }: GpaCellProps) = setAnchorEl(undefined); }, []); - useEffect(() => { - getGpaData(deptCode, courseNumber, instructors) - .then((data) => { - if (data) { - setGpa(data.gpa); - setInstructor(data.instructor); - } - }) - .catch(console.log); - }, [deptCode, courseNumber, instructors]); - return ( diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody/SectionTableBodyRow.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody/SectionTableBodyRow.tsx index 940d7d0df1..9575be8a65 100644 --- a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody/SectionTableBodyRow.tsx +++ b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody/SectionTableBodyRow.tsx @@ -29,6 +29,8 @@ interface SectionTableBodyRowProps { scheduleConflict: boolean; analyticsCategory: AnalyticsCategory; formattedTime: string | null; + gpa?: string; + gpaInstructor?: string; } // These components have too varied of types, any is fine here @@ -57,6 +59,8 @@ export const SectionTableBodyRow = memo((props: SectionTableBodyRowProps) => { scheduleConflict, analyticsCategory, formattedTime, + gpa, + gpaInstructor, } = props; const theme = useTheme(); @@ -155,6 +159,8 @@ export const SectionTableBodyRow = memo((props: SectionTableBodyRowProps) => { {...courseDetails} analyticsCategory={analyticsCategory} formattedTime={formattedTime} + gpa={gpa} + gpaInstructor={gpaInstructor} /> ); })} diff --git a/apps/antalmanac/src/stores/SectionFilterStore.ts b/apps/antalmanac/src/stores/SectionFilterStore.ts index 5134473229..7c66c838f6 100644 --- a/apps/antalmanac/src/stores/SectionFilterStore.ts +++ b/apps/antalmanac/src/stores/SectionFilterStore.ts @@ -6,6 +6,7 @@ export const SORT_OPTIONS = [ { value: 'time_asc', label: 'Time' }, { value: 'days_mwf', label: 'Date: MWF' }, { value: 'days_tuth', label: 'Date: TuTh' }, + { value: 'gpa_descending', label: 'GPA' }, ] as const; export type SortOption = (typeof SORT_OPTIONS)[number]['value']; From b520f193f73ac6e304a31c867e1dc91b50d1e55c Mon Sep 17 00:00:00 2001 From: bruhkookie Date: Tue, 21 Apr 2026 00:39:02 -0700 Subject: [PATCH 16/22] delete day filtering --- .../SectionTableBody/SectionTableBody.tsx | 18 ------------------ .../src/stores/SectionFilterStore.ts | 2 -- 2 files changed, 20 deletions(-) diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody/SectionTableBody.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody/SectionTableBody.tsx index f466267973..f4da77683b 100644 --- a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody/SectionTableBody.tsx +++ b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody/SectionTableBody.tsx @@ -18,12 +18,6 @@ function getMeetingStartMinutes(section: AASection): number { return meeting.startTime.hour * 60 + meeting.startTime.minute; } -function getMeetingDays(section: AASection): string { - const meeting = section.meetings[0]; - if (!meeting || meeting.timeIsTBA) return ''; - return meeting.days; -} - const STATUS_ORDER: Record = { OPEN: 0, NewOnly: 1, Waitl: 2, FULL: 3, '': 4 }; function sortSections(sections: AASection[], sortBy: SortOption, gpaMap: GpaMap): AASection[] { @@ -37,18 +31,6 @@ function sortSections(sections: AASection[], sortBy: SortOption, gpaMap: GpaMap) case 'time_asc': return getMeetingStartMinutes(a) - getMeetingStartMinutes(b); - case 'days_mwf': { - const aMatch = /[MWF]/.test(getMeetingDays(a)) ? 0 : 1; - const bMatch = /[MWF]/.test(getMeetingDays(b)) ? 0 : 1; - return aMatch - bMatch; - } - - case 'days_tuth': { - const aMatch = /Tu|Th/.test(getMeetingDays(a)) ? 0 : 1; - const bMatch = /Tu|Th/.test(getMeetingDays(b)) ? 0 : 1; - return aMatch - bMatch; - } - case 'gpa_descending': { const aGpa = parseFloat(gpaMap.get(a.sectionCode)?.gpa ?? ''); const bGpa = parseFloat(gpaMap.get(b.sectionCode)?.gpa ?? ''); diff --git a/apps/antalmanac/src/stores/SectionFilterStore.ts b/apps/antalmanac/src/stores/SectionFilterStore.ts index 7c66c838f6..6ff8096440 100644 --- a/apps/antalmanac/src/stores/SectionFilterStore.ts +++ b/apps/antalmanac/src/stores/SectionFilterStore.ts @@ -4,8 +4,6 @@ export const SORT_OPTIONS = [ { value: 'default', label: 'Default' }, { value: 'status', label: 'Status' }, { value: 'time_asc', label: 'Time' }, - { value: 'days_mwf', label: 'Date: MWF' }, - { value: 'days_tuth', label: 'Date: TuTh' }, { value: 'gpa_descending', label: 'GPA' }, ] as const; From 8f2fa33e4773c2890cba560241a1984685c8201d Mon Sep 17 00:00:00 2001 From: bruhkookie Date: Tue, 21 Apr 2026 01:03:19 -0700 Subject: [PATCH 17/22] refactor grade query logic to avoid dupe queries and race conditions' --- apps/antalmanac/src/lib/grades.ts | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/apps/antalmanac/src/lib/grades.ts b/apps/antalmanac/src/lib/grades.ts index 1319c260b9..d2e9cd15f5 100644 --- a/apps/antalmanac/src/lib/grades.ts +++ b/apps/antalmanac/src/lib/grades.ts @@ -24,14 +24,19 @@ class _Grades { // We need this because gradesCache destructures the data and doesn't retain whether we looked at one course or a whole department/GE cachedQueries: Set; + // Deduplicates concurrent queryGrades calls for the same key so N parallel callers share one network request. + pendingQueries: Map>; + constructor() { this.gradesCache = {}; this.cachedQueries = new Set(); + this.pendingQueries = new Map(); } clearCache() { Object.keys(this.gradesCache).forEach((key) => delete this.gradesCache[key]); //https://stackoverflow.com/a/19316873/14587004 this.cachedQueries = new Set(); + this.pendingQueries.clear(); } /* @@ -115,13 +120,25 @@ class _Grades { if (cacheOnly) return null; - const resp = await trpc.grades.aggregateGrades - .query({ department, courseNumber, instructor }) - .then((x) => x?.gradeDistribution ?? null); + const inFlight = this.pendingQueries.get(cacheKey); + if (inFlight) return inFlight; + + const promise = (async () => { + try { + const resp = await trpc.grades.aggregateGrades + .query({ department, courseNumber, instructor }) + .then((x) => x?.gradeDistribution ?? null); + + if (resp) this.gradesCache[cacheKey] = resp; - if (resp) this.gradesCache[cacheKey] = resp; + return resp; + } finally { + this.pendingQueries.delete(cacheKey); + } + })(); - return resp; + this.pendingQueries.set(cacheKey, promise); + return promise; }; } From 962dec900bf5a2fe6f5a341c6776dde73cc95ca8 Mon Sep 17 00:00:00 2001 From: bruhkookie Date: Tue, 21 Apr 2026 01:25:39 -0700 Subject: [PATCH 18/22] prevent promise from failing on grade queries --- .../SectionTableBody/SectionTableBody.tsx | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody/SectionTableBody.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody/SectionTableBody.tsx index f4da77683b..b45ddbe443 100644 --- a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody/SectionTableBody.tsx +++ b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody/SectionTableBody.tsx @@ -71,8 +71,17 @@ export function SectionTableBody({ Promise.all( courseDetails.sections.map(async (section) => { - const data = await getGpaData(courseDetails.deptCode, courseDetails.courseNumber, section.instructors); - return [section.sectionCode, data] as const; + try { + const data = await getGpaData( + courseDetails.deptCode, + courseDetails.courseNumber, + section.instructors + ); + return [section.sectionCode, data] as const; + } catch (error) { + console.error(`Failed to load GPA for ${section.sectionCode}`, error); + return [section.sectionCode, null] as const; + } }) ).then((entries) => { if (cancelled) return; From 2ffc12dbcef148e947a0dac3ec5fb165c47475bc Mon Sep 17 00:00:00 2001 From: bruhkookie Date: Tue, 5 May 2026 16:29:07 -0700 Subject: [PATCH 19/22] feat: filter sorts whole courses instead of individual sections --- .../RightPane/CoursePane/CourseRenderPane.tsx | 154 +++++++++++++++++- .../SectionTableBody/SectionTableBody.tsx | 88 +--------- .../SectionTableBodyCells/GpaCell.tsx | 8 +- .../SectionTableBody/SectionTableBodyRow.tsx | 6 - 4 files changed, 155 insertions(+), 101 deletions(-) diff --git a/apps/antalmanac/src/components/RightPane/CoursePane/CourseRenderPane.tsx b/apps/antalmanac/src/components/RightPane/CoursePane/CourseRenderPane.tsx index 30bad92c85..4ca8adcdaf 100644 --- a/apps/antalmanac/src/components/RightPane/CoursePane/CourseRenderPane.tsx +++ b/apps/antalmanac/src/components/RightPane/CoursePane/CourseRenderPane.tsx @@ -17,6 +17,7 @@ import { BLUE, PROJECTS_LINK } from '$src/globals'; import AppStore from '$stores/AppStore'; import { useCoursePaneStore } from '$stores/CoursePaneStore'; import { useHoveredStore } from '$stores/HoveredStore'; +import { type SortOption, useSectionFilterStore } from '$stores/SectionFilterStore'; import { useSessionStore } from '$stores/SessionStore'; import { useThemeStore } from '$stores/SettingsStore'; import { openSnackbar } from '$stores/SnackbarStore'; @@ -32,7 +33,7 @@ import { GE, } from '@packages/antalmanac-types'; import Image from 'next/image'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import LazyLoad from 'react-lazyload'; function getColors() { @@ -80,6 +81,93 @@ const flattenSOCObject = (SOCObject: WebsocAPIResponse): (WebsocSchool | WebsocD }, []); }; +type CourseGpaMap = Map; + +const STATUS_OPEN_RANK: Record = { OPEN: 3, NewOnly: 2, Waitl: 1, FULL: 0, '': 0 }; + +const courseGpaKey = (deptCode: string, courseNumber: string, instructor: string) => + `${deptCode}|${courseNumber}|${instructor}`; + +function getRepresentativeSections(course: AACourse): AASection[] { + const lecs = course.sections.filter((section) => section.sectionType === 'Lec'); + return lecs.length > 0 ? lecs : course.sections; +} + +function getMeetingStartMinutes(section: AASection): number { + const meeting = section.meetings[0]; + if (!meeting || meeting.timeIsTBA) return Infinity; + return meeting.startTime.hour * 60 + meeting.startTime.minute; +} + +function getCourseSortKey(course: AACourse, sortBy: SortOption, gpaMap: CourseGpaMap): number { + const sections = getRepresentativeSections(course); //lectures if course has lections, else everything else + + switch (sortBy) { + case 'time_asc': + return Math.min(...sections.map((section) => getMeetingStartMinutes(section))); + + case 'status': + return sections.reduce((sum, section) => sum + (STATUS_OPEN_RANK[section.status] ?? 0), 0); + + case 'gpa_descending': { + let best = -Infinity; + for (const section of sections) { + for (const instructor of section.instructors) { + if (instructor === 'STAFF') continue; + const gpa = gpaMap.get(courseGpaKey(course.deptCode, course.courseNumber, instructor)); + if (gpa !== undefined && gpa > best) best = gpa; + } + } + return best; + } + + default: + return 0; + } +} + +function sortCoursesInWindows( + items: (WebsocSchool | WebsocDepartment | AACourse)[], + sortBy: SortOption, + gpaMap: CourseGpaMap +): (WebsocSchool | WebsocDepartment | AACourse)[] { + if (sortBy === 'default') return items; + + const result: (WebsocSchool | WebsocDepartment | AACourse)[] = []; + let buffer: AACourse[] = []; + + const flush = () => { + if (buffer.length === 0) return; + const ascending = sortBy === 'time_asc'; + const sorted = [...buffer].sort((a, b) => { + const keyA = getCourseSortKey(a, sortBy, gpaMap); + const keyB = getCourseSortKey(b, sortBy, gpaMap); + return ascending ? keyA - keyB : keyB - keyA; + }); + result.push(...sorted); + buffer = []; + }; + + for (const item of items) { + if ('sections' in item) { + buffer.push(item); + } else { + flush(); + result.push(item); + } + } + flush(); + + return result; +} + +function getStableKey(item: WebsocSchool | WebsocDepartment | AACourse, fallbackIndex: number): string { + if ('sections' in item) return `course-${item.deptCode}-${item.courseNumber}`; + if ('courses' in item) return `dept-${item.deptCode}`; + if ('departments' in item) return `school-${item.schoolName}`; + return `idx-${fallbackIndex}`; +} + function getFilteredCourses( allCourses: (WebsocSchool | WebsocDepartment | AACourse)[] ): (WebsocSchool | WebsocDepartment | AACourse)[] { @@ -271,7 +359,9 @@ export default function CourseRenderPane(props: { id?: number }) { const [scheduleNames, setScheduleNames] = useState(AppStore.getScheduleNames()); const [unofferedCourses, setUnofferedCourses] = useState([]); const [searchedTerm, setSearchedTerm] = useState(() => getTermLongName(RightPaneStore.getFormData().term)); + const [gpaMap, setGpaMap] = useState(() => new Map()); + const sortBy = useSectionFilterStore((state) => state.sortBy); const setHoveredEvent = useHoveredStore((store) => store.setHoveredEvent); const getQueryParams = useCallback((searchData: CourseSearchParams) => { @@ -394,6 +484,50 @@ export default function CourseRenderPane(props: { id?: number }) { }; }, [loadCourses, props.id]); + useEffect(() => { + if (sortBy !== 'gpa_descending' || courseData.length === 0) return; + + let cancelled = false; + const requested = new Set(); + const tasks: Promise<{ key: string; gpa: number } | null>[] = []; + + for (const item of courseData) { + if (!('sections' in item)) continue; + const course = item; + for (const section of getRepresentativeSections(course)) { + for (const instructor of section.instructors) { + if (instructor === 'STAFF') continue; + const key = courseGpaKey(course.deptCode, course.courseNumber, instructor); + if (requested.has(key)) continue; + requested.add(key); + tasks.push( + Grades.queryGrades(course.deptCode, course.courseNumber, instructor, false) + .then((grades) => (grades?.averageGPA ? { key, gpa: grades.averageGPA } : null)) + .catch(() => null) + ); + } + } + } + + Promise.all(tasks).then((results) => { + if (cancelled) return; + const next: CourseGpaMap = new Map(); + for (const result of results) { + if (result) next.set(result.key, result.gpa); + } + setGpaMap(next); + }); + + return () => { + cancelled = true; + }; + }, [courseData, sortBy]); + + const sortedCourseData = useMemo( + () => sortCoursesInWindows(courseData, sortBy, gpaMap), + [courseData, sortBy, gpaMap] + ); + /** * Removes hovered course when component unmounts * Handles edge cases where the Section Table is removed, rather than the mouse @@ -411,6 +545,7 @@ export default function CourseRenderPane(props: { id?: number }) { {Object.entries(RightPaneStore.getWarningMessages()).map(([warningType, messages]) => { return messages.map((message) => ( + //takes each message and turns it into an alert )); })} + {unofferedCourses.map((course) => { return ( @@ -437,14 +573,20 @@ export default function CourseRenderPane(props: { id?: number }) { <> - {courseData.map((_: WebsocSchool | WebsocDepartment | AACourse, index: number) => { + {sortedCourseData.map((item, index) => { let heightEstimate = 200; - if ((courseData[index] as AACourse).sections !== undefined) - heightEstimate = (courseData[index] as AACourse).sections.length * 60 + 20 + 40; + if ((item as AACourse).sections !== undefined) + heightEstimate = (item as AACourse).sections.length * 60 + 20 + 40; return ( - + {SectionTableWrapped(index, { - courseData: courseData, + courseData: sortedCourseData, scheduleNames: scheduleNames, })} diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody/SectionTableBody.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody/SectionTableBody.tsx index b45ddbe443..596f0ef2d7 100644 --- a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody/SectionTableBody.tsx +++ b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody/SectionTableBody.tsx @@ -1,49 +1,10 @@ -import { TableBody } from '@mui/material'; -import { AACourse, AASection } from '@packages/antalmanac-types'; -import { useCallback, useEffect, useState, useMemo } from 'react'; - -import { getGpaData } from '$components/RightPane/SectionTable/SectionTableBody/SectionTableBodyCells/GpaCell'; import { SectionTableBodyRow } from '$components/RightPane/SectionTable/SectionTableBody/SectionTableBodyRow'; import { AnalyticsCategory } from '$lib/analytics/analytics'; import AppStore from '$stores/AppStore'; -import { type SortOption, useSectionFilterStore } from '$stores/SectionFilterStore'; import { normalizeTime, parseDaysString } from '$stores/calendarizeHelpers'; - -export type GpaEntry = { gpa: string; instructor: string }; -export type GpaMap = Map; - -function getMeetingStartMinutes(section: AASection): number { - const meeting = section.meetings[0]; - if (!meeting || meeting.timeIsTBA) return Infinity; - return meeting.startTime.hour * 60 + meeting.startTime.minute; -} - -const STATUS_ORDER: Record = { OPEN: 0, NewOnly: 1, Waitl: 2, FULL: 3, '': 4 }; - -function sortSections(sections: AASection[], sortBy: SortOption, gpaMap: GpaMap): AASection[] { - if (sortBy === 'default') return sections; - - return [...sections].sort((a, b) => { - switch (sortBy) { - case 'status': - return (STATUS_ORDER[a.status] ?? 4) - (STATUS_ORDER[b.status] ?? 4); - - case 'time_asc': - return getMeetingStartMinutes(a) - getMeetingStartMinutes(b); - - case 'gpa_descending': { - const aGpa = parseFloat(gpaMap.get(a.sectionCode)?.gpa ?? ''); - const bGpa = parseFloat(gpaMap.get(b.sectionCode)?.gpa ?? ''); - const aValue = Number.isNaN(aGpa) ? -Infinity : aGpa; - const bValue = Number.isNaN(bGpa) ? -Infinity : bGpa; - return bValue - aValue; - } - - default: - return 0; - } - }); -} +import { TableBody } from '@mui/material'; +import { AACourse, AASection } from '@packages/antalmanac-types'; +import { useCallback, useEffect, useState } from 'react'; interface SectionTableBodyProps { courseDetails: AACourse; @@ -63,39 +24,6 @@ export function SectionTableBody({ formattedTime, }: SectionTableBodyProps) { const [calendarEvents, setCalendarEvents] = useState(() => AppStore.getCourseEventsInCalendar()); - const [gpaMap, setGpaMap] = useState(() => new Map()); - const sortBy = useSectionFilterStore((state) => state.sortBy); - - useEffect(() => { - let cancelled = false; - - Promise.all( - courseDetails.sections.map(async (section) => { - try { - const data = await getGpaData( - courseDetails.deptCode, - courseDetails.courseNumber, - section.instructors - ); - return [section.sectionCode, data] as const; - } catch (error) { - console.error(`Failed to load GPA for ${section.sectionCode}`, error); - return [section.sectionCode, null] as const; - } - }) - ).then((entries) => { - if (cancelled) return; - const next: GpaMap = new Map(); - for (const [code, data] of entries) { - if (data) next.set(code, data); - } - setGpaMap(next); - }); - - return () => { - cancelled = true; - }; - }, [courseDetails]); /** * Additional information about the current section being rendered. @@ -153,16 +81,10 @@ export function SectionTableBody({ }; }, [updateCalendarEvents]); - const sortedSections = useMemo( - () => sortSections(courseDetails.sections, sortBy, gpaMap), - [courseDetails.sections, sortBy, gpaMap] - ); - return ( - {sortedSections.map((section) => { + {courseDetails.sections.map((section) => { const conflict = scheduleConflict(section); - const gpaEntry = gpaMap.get(section.sectionCode); return ( ); })} diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody/SectionTableBodyCells/GpaCell.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody/SectionTableBodyCells/GpaCell.tsx index f6357e8617..5b68b5eca9 100644 --- a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody/SectionTableBodyCells/GpaCell.tsx +++ b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody/SectionTableBodyCells/GpaCell.tsx @@ -6,7 +6,7 @@ import { Grades } from '$lib/grades'; import { ButtonBase, Popover } from '@mui/material'; import { useCallback, useEffect, useState } from 'react'; -export async function getGpaData(deptCode: string, courseNumber: string, instructors: string[]) { +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 @@ -30,11 +30,9 @@ interface GpaCellProps { deptCode: string; courseNumber: string; instructors: string[]; - gpa?: string; - gpaInstructor?: string; } -export const GpaCell = ({ deptCode, courseNumber, instructors, gpa: gpaProp }: GpaCellProps) => { +export const GpaCell = ({ deptCode, courseNumber, instructors }: GpaCellProps) => { const isMobile = useIsMobile(); const secondaryColor = useSecondaryColor(); @@ -69,7 +67,7 @@ export const GpaCell = ({ deptCode, courseNumber, instructors, gpa: gpaProp }: G sx={{ fontFamily: 'inherit', fontSize: 'unset', color: secondaryColor, fontWeight: 700 }} onClick={handleClick} > - {loading ? null : gpaProp || gpa || 'GPA'} + {loading ? null : gpa || 'GPA'} { scheduleConflict, analyticsCategory, formattedTime, - gpa, - gpaInstructor, } = props; const theme = useTheme(); @@ -158,8 +154,6 @@ export const SectionTableBodyRow = memo((props: SectionTableBodyRowProps) => { {...courseDetails} analyticsCategory={analyticsCategory} formattedTime={formattedTime} - gpa={gpa} - gpaInstructor={gpaInstructor} /> ); })} From 9e8d5e42e98e27f65cca3dacfac8e39f03936277 Mon Sep 17 00:00:00 2001 From: bruhkookie Date: Tue, 5 May 2026 16:53:58 -0700 Subject: [PATCH 20/22] undo random prettier formats to clean diff --- .../components/RightPane/CoursePane/CourseRenderPane.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/antalmanac/src/components/RightPane/CoursePane/CourseRenderPane.tsx b/apps/antalmanac/src/components/RightPane/CoursePane/CourseRenderPane.tsx index 4ca8adcdaf..5a157cf70d 100644 --- a/apps/antalmanac/src/components/RightPane/CoursePane/CourseRenderPane.tsx +++ b/apps/antalmanac/src/components/RightPane/CoursePane/CourseRenderPane.tsx @@ -491,12 +491,12 @@ export default function CourseRenderPane(props: { id?: number }) { const requested = new Set(); const tasks: Promise<{ key: string; gpa: number } | null>[] = []; - for (const item of courseData) { - if (!('sections' in item)) continue; - const course = item; + for (const course of courseData) { + if (!('sections' in course)) continue; for (const section of getRepresentativeSections(course)) { + //get the lecs / everything else if no lecs for (const instructor of section.instructors) { - if (instructor === 'STAFF') continue; + if (instructor === 'STAFF') continue; //teachers displayed as 'STAFF' have no data const key = courseGpaKey(course.deptCode, course.courseNumber, instructor); if (requested.has(key)) continue; requested.add(key); From 870d9f1b9d90121a84b35842f4b008e0456aa444 Mon Sep 17 00:00:00 2001 From: bruhkookie Date: Tue, 5 May 2026 17:10:43 -0700 Subject: [PATCH 21/22] fix: set gpa filter hook to falback onto the cache --- .../src/components/RightPane/CoursePane/CourseRenderPane.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/antalmanac/src/components/RightPane/CoursePane/CourseRenderPane.tsx b/apps/antalmanac/src/components/RightPane/CoursePane/CourseRenderPane.tsx index 5a157cf70d..84f90468ed 100644 --- a/apps/antalmanac/src/components/RightPane/CoursePane/CourseRenderPane.tsx +++ b/apps/antalmanac/src/components/RightPane/CoursePane/CourseRenderPane.tsx @@ -501,7 +501,7 @@ export default function CourseRenderPane(props: { id?: number }) { if (requested.has(key)) continue; requested.add(key); tasks.push( - Grades.queryGrades(course.deptCode, course.courseNumber, instructor, false) + Grades.queryGrades(course.deptCode, course.courseNumber, instructor, true) .then((grades) => (grades?.averageGPA ? { key, gpa: grades.averageGPA } : null)) .catch(() => null) ); From b1c0ca157a2d22a674c183ca977950c95edb7102 Mon Sep 17 00:00:00 2001 From: bruhkookie Date: Tue, 5 May 2026 17:17:34 -0700 Subject: [PATCH 22/22] fix truthiness check on averagegpa --- .../src/components/RightPane/CoursePane/CourseRenderPane.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/antalmanac/src/components/RightPane/CoursePane/CourseRenderPane.tsx b/apps/antalmanac/src/components/RightPane/CoursePane/CourseRenderPane.tsx index 84f90468ed..88d6d992b0 100644 --- a/apps/antalmanac/src/components/RightPane/CoursePane/CourseRenderPane.tsx +++ b/apps/antalmanac/src/components/RightPane/CoursePane/CourseRenderPane.tsx @@ -502,7 +502,7 @@ export default function CourseRenderPane(props: { id?: number }) { requested.add(key); tasks.push( Grades.queryGrades(course.deptCode, course.courseNumber, instructor, true) - .then((grades) => (grades?.averageGPA ? { key, gpa: grades.averageGPA } : null)) + .then((grades) => (grades?.averageGPA != null ? { key, gpa: grades.averageGPA } : null)) .catch(() => null) ); }