diff --git a/apps/antalmanac/scripts/get-search-data.ts b/apps/antalmanac/scripts/get-search-data.ts index 5bea755ba..9a6421a50 100644 --- a/apps/antalmanac/scripts/get-search-data.ts +++ b/apps/antalmanac/scripts/get-search-data.ts @@ -8,12 +8,7 @@ import type { DepartmentSearchResult, CoursesFilteredAPIResult, } from '@packages/antalmanac-types'; -import type { - WebsocAPIResponse, - WebsocAPIResult, - WebsocCourse, - WebsocDepartment, -} from '@packages/anteater-api-types'; +import type { WebsocAPIResponse, WebsocAPIResult, WebsocCourse, WebsocDepartment } from '@packages/anteater-api-types'; import { fetchAnteaterAPI, queryGraphQL } from '../src/backend/lib/helpers'; import { parseSectionCodes, SectionCodesGraphQLResponse, termData } from '../src/backend/lib/term-section-codes'; @@ -149,11 +144,11 @@ async function main() { ` import type { CourseSearchResult, DepartmentSearchResult } from "@packages/antalmanac-types"; export const departments: Array = ${JSON.stringify( - Array.from(deptMap.values()) - )}; + Array.from(deptMap.values()) + )}; export const courses: Array = ${JSON.stringify( - Array.from(courseMap.values()) - )}; + Array.from(courseMap.values()) + )}; ` ); diff --git a/apps/antalmanac/src/components/RightPane/CoursePane/CourseRenderPane.tsx b/apps/antalmanac/src/components/RightPane/CoursePane/CourseRenderPane.tsx index 30bad92c8..9431791db 100644 --- a/apps/antalmanac/src/components/RightPane/CoursePane/CourseRenderPane.tsx +++ b/apps/antalmanac/src/components/RightPane/CoursePane/CourseRenderPane.tsx @@ -11,6 +11,7 @@ import analyticsEnum from '$lib/analytics/analytics'; import trpc from '$lib/api/trpc'; import { Grades } from '$lib/grades'; import { getLocalStorageRecruitmentDismissalTime, setLocalStorageRecruitmentDismissalTime } from '$lib/localStorage'; +import { getMultiGeCourseKey, isMultiGeSelection, queryManualSearchCourses } from '$lib/multiGeSearch'; import { getTermLongName } from '$lib/termData'; import { WebSOC } from '$lib/websoc'; import { BLUE, PROJECTS_LINK } from '$src/globals'; @@ -80,23 +81,65 @@ const flattenSOCObject = (SOCObject: WebsocAPIResponse): (WebsocSchool | WebsocD }, []); }; +function isCourseEntry(item: WebsocSchool | WebsocDepartment | AACourse): item is AACourse { + return 'sections' in item && 'deptCode' in item && 'courseNumber' in item; +} + +function cleanHeaders( + items: (WebsocSchool | WebsocDepartment | AACourse)[] +): (WebsocSchool | WebsocDepartment | AACourse)[] { + const result: (WebsocSchool | WebsocDepartment | AACourse)[] = []; + let pendingSchool: WebsocSchool | null = null; + let pendingDept: WebsocDepartment | null = null; + + for (const item of items) { + if ('departments' in item) { + pendingSchool = item as WebsocSchool; + pendingDept = null; + } else if ('courses' in item) { + pendingDept = item as WebsocDepartment; + } else { + if (pendingSchool) { + result.push(pendingSchool); + pendingSchool = null; + } + if (pendingDept) { + result.push(pendingDept); + pendingDept = null; + } + result.push(item); + } + } + + return result; +} + function getFilteredCourses( allCourses: (WebsocSchool | WebsocDepartment | AACourse)[] ): (WebsocSchool | WebsocDepartment | AACourse)[] { const { manualSearchEnabled } = useCoursePaneStore.getState(); const { filterTakenCourses, userTakenCourses } = useSessionStore.getState(); if (manualSearchEnabled && filterTakenCourses && userTakenCourses.size > 0) { - return allCourses.filter((item) => { - if ('sections' in item && 'deptCode' in item && 'courseNumber' in item) { + const filtered = allCourses.filter((item) => { + if (isCourseEntry(item)) { const courseKey = `${item.deptCode}${item.courseNumber}`.replace(/\s+/g, ''); return !userTakenCourses.has(courseKey); } return true; }); + return cleanHeaders(filtered); } return allCourses; } +const getFilteredAndCourseCount = ( + flattenedCourseData: (WebsocSchool | WebsocDepartment | AACourse)[], + sharedCourseKeys: Set +) => + flattenedCourseData.filter( + (item) => 'sections' in item && sharedCourseKeys.has(getMultiGeCourseKey(item.deptCode, item.courseNumber)) + ).length; + const RecruitmentBanner = () => { const [bannerVisibility, setBannerVisibility] = useState(true); const theme = useTheme(); @@ -266,6 +309,9 @@ const ErrorMessage = () => { export default function CourseRenderPane(props: { id?: number }) { const [websocResp, setWebsocResp] = useState(); const [courseData, setCourseData] = useState<(WebsocSchool | WebsocDepartment | AACourse)[]>([]); + const [sharedCourseKeys, setSharedCourseKeys] = useState>(new Set()); + const [andCourseCount, setAndCourseCount] = useState(0); + const [andSchoolCount, setAndSchoolCount] = useState(0); const [loading, setLoading] = useState(true); const [error, setError] = useState(false); const [scheduleNames, setScheduleNames] = useState(AppStore.getScheduleNames()); @@ -273,6 +319,7 @@ export default function CourseRenderPane(props: { id?: number }) { const [searchedTerm, setSearchedTerm] = useState(() => getTermLongName(RightPaneStore.getFormData().term)); const setHoveredEvent = useHoveredStore((store) => store.setHoveredEvent); + const filterTakenCourses = useSessionStore((store) => store.filterTakenCourses); const getQueryParams = useCallback((searchData: CourseSearchParams) => { const websocQueryParams = { @@ -319,6 +366,8 @@ export default function CourseRenderPane(props: { id?: number }) { try { const multiSearchData = RightPaneStore.getMultiSearchData(); let websocJsonResp; + let fetchedSharedCourseKeys = new Set(); + let fetchedAndSchoolCount = 0; if (multiSearchData.length > 0) { const { year, quarter } = RightPaneStore.getTermParts(); const offeredCourses: Record[] = []; @@ -344,17 +393,23 @@ export default function CourseRenderPane(props: { id?: number }) { } else { const formData = RightPaneStore.getFormData(); const { websocQueryParams, gradesQueryParams } = getQueryParams(formData); - const [websocJsonResponse, _] = await Promise.all([ - websocQueryParams.units.includes(',') - ? WebSOC.queryMultipleOfField(websocQueryParams, 'units') - : WebSOC.query(websocQueryParams), - queryGrades(gradesQueryParams), - ]); + gradesQueryParams.ge = + formData.ge !== 'ANY' && !formData.ge.includes(',') ? (formData.ge as GE) : ('ANY' as GE); + const [{ response: websocJsonResponse, sharedCourseKeys: sck, andSchoolCount: asc }] = + await Promise.all([queryManualSearchCourses(websocQueryParams), queryGrades(gradesQueryParams)]); + websocJsonResp = websocJsonResponse; + fetchedSharedCourseKeys = sck; + fetchedAndSchoolCount = asc; } + setWebsocResp(websocJsonResp); const allCourses = flattenSOCObject(websocJsonResp); - setCourseData(getFilteredCourses(allCourses)); + const filteredCourses = getFilteredCourses(allCourses); + setCourseData(filteredCourses); + setSharedCourseKeys(fetchedSharedCourseKeys); + setAndSchoolCount(fetchedAndSchoolCount); + setAndCourseCount(getFilteredAndCourseCount(filteredCourses, fetchedSharedCourseKeys)); setSearchedTerm(getTermLongName(RightPaneStore.getFormData().term)); } catch (error) { console.error(error); @@ -369,13 +424,17 @@ export default function CourseRenderPane(props: { id?: number }) { setScheduleNames(AppStore.getScheduleNames()); }; + const hasRenderableCourseResults = courseData.some(isCourseEntry); + useEffect(() => { const changeColors = () => { if (websocResp == null) { return; } const flattened = flattenSOCObject(websocResp); - setCourseData(getFilteredCourses(flattened)); + const filteredCourses = getFilteredCourses(flattened); + setCourseData(filteredCourses); + setAndCourseCount(getFilteredAndCourseCount(filteredCourses, sharedCourseKeys)); }; AppStore.on('currentScheduleIndexChange', changeColors); @@ -383,7 +442,7 @@ export default function CourseRenderPane(props: { id?: number }) { return () => { AppStore.off('currentScheduleIndexChange', changeColors); }; - }, [websocResp]); + }, [sharedCourseKeys, websocResp]); useEffect(() => { loadCourses(); @@ -405,6 +464,18 @@ export default function CourseRenderPane(props: { id?: number }) { }; }, [setHoveredEvent]); + const currGeSelection = RightPaneStore.getFormData().ge; + const isMultiGeSearch = isMultiGeSelection(currGeSelection); + const showNoIntersection = isMultiGeSearch && andCourseCount === 0; + let orBannerIndex = -1; + if (isMultiGeSearch && !showNoIntersection) { + let schoolCount = 0; + orBannerIndex = courseData.findIndex((item) => { + if (!('departments' in item)) return false; + return schoolCount++ === andSchoolCount; + }); + } + return ( <> @@ -422,6 +493,9 @@ export default function CourseRenderPane(props: { id?: number }) { )); })} + {filterTakenCourses && !hasRenderableCourseResults && ( + Filtered taken courses is toggled. + )} {unofferedCourses.map((course) => { return ( @@ -431,18 +505,48 @@ export default function CourseRenderPane(props: { id?: number }) { })} {loading ? ( - ) : error || courseData.length === 0 ? ( + ) : error || !hasRenderableCourseResults ? ( ) : ( <> + {showNoIntersection && ( + + No courses match all selected GEs. The results below match at least one selected GE. + + )} {courseData.map((_: WebsocSchool | WebsocDepartment | AACourse, index: number) => { let heightEstimate = 200; if ((courseData[index] as AACourse).sections !== undefined) heightEstimate = (courseData[index] as AACourse).sections.length * 60 + 20 + 40; return ( + {index === orBannerIndex && ( + + The courses below satisfy at least one of the selected GEs. + + )} {SectionTableWrapped(index, { courseData: courseData, scheduleNames: scheduleNames, diff --git a/apps/antalmanac/src/components/RightPane/CoursePane/SearchForm/AdvancedSearch/AdvancedSearchTextFields.tsx b/apps/antalmanac/src/components/RightPane/CoursePane/SearchForm/AdvancedSearch/AdvancedSearchTextFields.tsx index 7d7e2e4d2..89bd7a0d6 100644 --- a/apps/antalmanac/src/components/RightPane/CoursePane/SearchForm/AdvancedSearch/AdvancedSearchTextFields.tsx +++ b/apps/antalmanac/src/components/RightPane/CoursePane/SearchForm/AdvancedSearch/AdvancedSearchTextFields.tsx @@ -8,7 +8,7 @@ import { LabeledSelect } from '$components/RightPane/CoursePane/SearchForm/Label import { LabeledTextField } from '$components/RightPane/CoursePane/SearchForm/LabeledInputs/LabeledTextField'; import { LabeledTimePicker } from '$components/RightPane/CoursePane/SearchForm/LabeledInputs/LabeledTimePicker'; import RightPaneStore from '$components/RightPane/RightPaneStore'; -import { safeUnreachableCase } from '$lib/utils'; +import { replaceUrlSearchParams, safeUnreachableCase } from '$lib/utils'; import { useSessionStore } from '$stores/SessionStore'; import { openSnackbar } from '$stores/SnackbarStore'; import { MenuItem, Box, type SelectChangeEvent, Checkbox, ListItemText, Tooltip, Typography } from '@mui/material'; @@ -101,18 +101,13 @@ export function AdvancedSearchTextFields() { }, [resetField]); const updateValue = (name: AdvancedSearchParam, stringValue: string) => { - const stateObj = { url: 'url' }; - const url = new URL(window.location.href); - const urlParam = new URLSearchParams(url.search); - if (stringValue !== '') { - urlParam.set(name, String(stringValue)); - } else { - urlParam.delete(name); - } - - const param = urlParam.toString(); - const newUrl = `${param.trim() ? '?' : ''}${param}`; - history.replaceState(stateObj, 'url', '/' + newUrl); + replaceUrlSearchParams((params) => { + if (stringValue !== '') { + params.set(name, String(stringValue)); + } else { + params.delete(name); + } + }); RightPaneStore.updateFormValue(name, stringValue); }; @@ -133,24 +128,24 @@ export function AdvancedSearchTextFields() { } if (name === 'online') { - const url = new URL(window.location.href); - const urlParam = new URLSearchParams(url.search); const checked = (event as ChangeEvent).target.value === 'true'; - if (checked) { - setBuilding('ON'); - setRoom('LINE'); - RightPaneStore.updateFormValue('building', 'ON'); - RightPaneStore.updateFormValue('room', 'LINE'); - urlParam.set('building', 'ON'); - urlParam.set('room', 'LINE'); - } else { - setBuilding(''); - setRoom(''); - RightPaneStore.updateFormValue('building', ''); - RightPaneStore.updateFormValue('room', ''); - urlParam.delete('building'); - urlParam.delete('room'); - } + const nextBuilding = checked ? 'ON' : ''; + const nextRoom = checked ? 'LINE' : ''; + + setBuilding(nextBuilding); + setRoom(nextRoom); + RightPaneStore.updateFormValue('building', nextBuilding); + RightPaneStore.updateFormValue('room', nextRoom); + + replaceUrlSearchParams((params) => { + if (nextBuilding) { + params.set('building', nextBuilding); + params.set('room', nextRoom); + } else { + params.delete('building'); + params.delete('room'); + } + }); return; } @@ -211,12 +206,7 @@ export function AdvancedSearchTextFields() { openSnackbar('warning', 'Invalid roadmap selection. All courses shown.'); setExcludeRoadmapCourses(''); RightPaneStore.updateFormValue('excludeRoadmapCourses', ''); - - const url = new URL(window.location.href); - const params = new URLSearchParams(url.search); - params.delete('excludeRoadmapCourses'); - const newUrl = params.toString() ? `${url.pathname}?${params.toString()}` : url.pathname; - history.replaceState({}, '', newUrl); + replaceUrlSearchParams((params) => params.delete('excludeRoadmapCourses')); } }, [roadmaps, excludeRoadmapCourses]); diff --git a/apps/antalmanac/src/components/RightPane/CoursePane/SearchForm/GeSelector.tsx b/apps/antalmanac/src/components/RightPane/CoursePane/SearchForm/GeSelector.tsx index f302716d8..ad5cc93c3 100644 --- a/apps/antalmanac/src/components/RightPane/CoursePane/SearchForm/GeSelector.tsx +++ b/apps/antalmanac/src/components/RightPane/CoursePane/SearchForm/GeSelector.tsx @@ -1,49 +1,37 @@ -import { MenuItem, type SelectChangeEvent } from '@mui/material'; -import { useEffect, useCallback, useState } from 'react'; - +import { ANY_GE, GE_LIST } from '$components/RightPane/CoursePane/SearchForm/constants'; import { LabeledSelect } from '$components/RightPane/CoursePane/SearchForm/LabeledInputs/LabeledSelect'; import RightPaneStore from '$components/RightPane/RightPaneStore'; +import { getSelectedGEs, normalizeGeSelection } from '$lib/multiGeSearch'; +import { replaceUrlSearchParams } from '$lib/utils'; +import { Checkbox, ListItemText, MenuItem, type SelectChangeEvent } from '@mui/material'; +import { useEffect, useCallback, useState } from 'react'; -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; +const getLabel = (value: string) => GE_LIST.find((ge) => ge.value === value)?.label ?? value; +const getShortLabel = (value: string) => GE_LIST.find((ge) => ge.value === value)?.shortLabel ?? value; export function GeSelector() { const [ge, setGe] = useState(() => RightPaneStore.getFormData().ge); + const selectedGEs = getSelectedGEs(ge); - const handleChange = (event: SelectChangeEvent) => { + const handleChange = (event: SelectChangeEvent) => { const value = event.target.value; + const values = (typeof value === 'string' ? value.split(',') : value).filter(Boolean); + const selectedValues = values.includes(ANY_GE) ? [] : values.filter((currentValue) => currentValue !== ANY_GE); + const searchValue = normalizeGeSelection(selectedValues.join(',')); - setGe(value); - RightPaneStore.updateFormValue('ge', value); - - const stateObj = { url: 'url' }; - const url = new URL(window.location.href); - const urlParam = new URLSearchParams(url.search); + setGe(searchValue); + RightPaneStore.updateFormValue('ge', searchValue); - 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); + replaceUrlSearchParams((params) => { + params.delete('ge'); + if (searchValue !== ANY_GE) { + params.set('ge', searchValue); + } + }); }; const resetField = useCallback(() => { - setGe(RightPaneStore.getFormData().ge); + setGe(normalizeGeSelection(RightPaneStore.getFormData().ge)); }, []); useEffect(() => { @@ -58,8 +46,16 @@ export function GeSelector() { { + const values = selected as string[]; + if (values.length === 0) return getLabel(ANY_GE); + if (values.length === 1) return getLabel(values[0]); + return values.map((value) => getShortLabel(value)).join(', '); + }, sx: { width: '100%', }, @@ -67,9 +63,13 @@ export function GeSelector() { isAligned={true} > {GE_LIST.map((category) => { + const isChecked = + category.value === ANY_GE ? selectedGEs.length === 0 : selectedGEs.includes(category.value); + return ( - - {category.label} + + + ); })} diff --git a/apps/antalmanac/src/components/RightPane/CoursePane/SearchForm/constants.ts b/apps/antalmanac/src/components/RightPane/CoursePane/SearchForm/constants.ts index 2e414a7e3..7ea1454fb 100644 --- a/apps/antalmanac/src/components/RightPane/CoursePane/SearchForm/constants.ts +++ b/apps/antalmanac/src/components/RightPane/CoursePane/SearchForm/constants.ts @@ -30,3 +30,19 @@ export const MANUAL_SEARCH_PARAMS = [ export type ManualSearchParam = (typeof MANUAL_SEARCH_PARAMS)[number]; export const PLANNER_SEARCH_PARAM = 'importRoadmap'; + +export const GE_LIST = [ + { value: 'ANY', label: "All: Don't filter for GE", shortLabel: 'All GEs' }, + { value: 'GE-1A', label: 'GE Ia (1a): Lower Division Writing', shortLabel: 'GE Ia (1a)' }, + { value: 'GE-1B', label: 'GE Ib (1b): Upper Division Writing', shortLabel: 'GE Ib (1b)' }, + { value: 'GE-2', label: 'GE II (2): Science and Technology', shortLabel: 'GE II (2)' }, + { value: 'GE-3', label: 'GE III (3): Social and Behavioral Sciences', shortLabel: 'GE III (3)' }, + { value: 'GE-4', label: 'GE IV (4): Arts and Humanities', shortLabel: 'GE IV (4)' }, + { value: 'GE-5A', label: 'GE Va (5a): Quantitative Literacy', shortLabel: 'GE Va (5a)' }, + { value: 'GE-5B', label: 'GE Vb (5b): Formal Reasoning', shortLabel: 'GE Vb (5b)' }, + { value: 'GE-6', label: 'GE VI (6): Language other than English', shortLabel: 'GE VI (6)' }, + { value: 'GE-7', label: 'GE VII (7): Multicultural Studies', shortLabel: 'GE VII (7)' }, + { value: 'GE-8', label: 'GE VIII (8): International/Global Issues', shortLabel: 'GE VIII (8)' }, +] as const; + +export const ANY_GE = GE_LIST[0].value; diff --git a/apps/antalmanac/src/components/RightPane/RightPaneStore.ts b/apps/antalmanac/src/components/RightPane/RightPaneStore.ts index c19f0778d..4170d2380 100644 --- a/apps/antalmanac/src/components/RightPane/RightPaneStore.ts +++ b/apps/antalmanac/src/components/RightPane/RightPaneStore.ts @@ -5,6 +5,7 @@ import { BasicSearchParam, ManualSearchParam, } from '$components/RightPane/CoursePane/SearchForm/constants'; +import { normalizeGeSelection } from '$lib/multiGeSearch'; import { getDefaultTerm, isTermAvailable } from '$lib/termData'; import { openSnackbar } from '$stores/SnackbarStore'; @@ -58,11 +59,39 @@ class RightPaneStore extends EventEmitter { private urlCourseNumValue: string; private urlDeptValue: string; + private normalizeGeQueryParam = (search: URLSearchParams) => { + const rawGeValue = search.get('ge') ?? search.get('GE'); + if (rawGeValue == null) { + return; + } + + const normalizedGe = normalizeGeSelection(rawGeValue); + const currentGe = search.get('ge'); + const hadUppercaseGeParam = search.has('GE'); + + search.delete('GE'); + if (normalizedGe === 'ANY') { + search.delete('ge'); + } else { + search.set('ge', normalizedGe); + } + + const wasChanged = (currentGe ?? '') !== (normalizedGe === 'ANY' ? '' : normalizedGe) || hadUppercaseGeParam; + if (!wasChanged) { + return; + } + + const nextQuery = search.toString(); + const nextURL = `${window.location.pathname}${nextQuery ? `?${nextQuery}` : ''}${window.location.hash}`; + history.replaceState({ url: 'url' }, 'url', nextURL); + }; + constructor() { super(); this.setMaxListeners(15); this.formData = structuredClone(defaultFormValues); const search = new URLSearchParams(window.location.search); + this.normalizeGeQueryParam(search); this.multiSearchData = []; this.warningMessages = { [CourseSearchWarningType.TermUnavailable]: [] }; this.urlSectionCodeValue = search.get('sectionCode') || ''; @@ -81,7 +110,7 @@ class RightPaneStore extends EventEmitter { const paramValue = search.get(field) || search.get(field.toUpperCase()); if (paramValue !== null) { - this.formData[field] = paramValue; + this.formData[field] = field === 'ge' ? normalizeGeSelection(paramValue) : paramValue; } }); diff --git a/apps/antalmanac/src/lib/multiGeSearch.ts b/apps/antalmanac/src/lib/multiGeSearch.ts new file mode 100644 index 000000000..97452ff7f --- /dev/null +++ b/apps/antalmanac/src/lib/multiGeSearch.ts @@ -0,0 +1,130 @@ +import { ANY_GE, GE_LIST } from '$components/RightPane/CoursePane/SearchForm/constants'; +import { WebSOC } from '$lib/websoc'; +import { WebsocAPIResponse, WebsocDepartment, WebsocSchool } from '@packages/antalmanac-types'; + +const VALID_GES: Set = new Set(GE_LIST.map((option) => option.value).filter((value) => value !== ANY_GE)); + +const getCourseKey = (deptCode: string, courseNumber: string) => `${deptCode}::${courseNumber}`.replace(/\s+/g, ''); + +const parseSelectedGEs = (ge: string) => { + const validGEs = ge + .split(',') + .map((value) => value.trim().toUpperCase()) + .filter((value) => VALID_GES.has(value)); + + return validGEs.length === 0 ? [] : [...new Set(validGEs)]; +}; + +export const getSelectedGEs = (ge: string) => parseSelectedGEs(ge); +export const normalizeGeSelection = (ge: string) => { + const selectedGEs = parseSelectedGEs(ge); + return selectedGEs.length > 0 ? selectedGEs.join(',') : ANY_GE; +}; +export const isMultiGeSelection = (ge: string) => parseSelectedGEs(ge).length > 1; + +const getCourseKeys = (response: WebsocAPIResponse) => + new Set( + response.schools.flatMap((school) => + school.departments.flatMap((department) => + department.courses.map((course) => getCourseKey(course.deptCode, course.courseNumber)) + ) + ) + ); + +const queryWebsoc = (params: Record) => + params.units.includes(',') ? WebSOC.queryMultipleOfField(params, 'units') : WebSOC.query(params); + +const getSharedCourseKeys = (responses: WebsocAPIResponse[]) => { + const [firstResponse, ...restResponses] = responses; + let sharedCourseKeys = getCourseKeys(firstResponse); + + for (const response of restResponses) { + const keys = getCourseKeys(response); + sharedCourseKeys = new Set([...sharedCourseKeys].filter((key) => keys.has(key))); + } + + return sharedCourseKeys; +}; + +const buildAnd = (firstResponse: WebsocAPIResponse, sharedCourseKeys: Set) => + firstResponse.schools + .map((school) => ({ + ...school, + departments: school.departments + .map((department) => ({ + ...department, + courses: department.courses.filter((course) => + sharedCourseKeys.has(getCourseKey(course.deptCode, course.courseNumber)) + ), + })) + .filter((department) => department.courses.length > 0), + })) + .filter((school) => school.departments.length > 0); + +const buildOr = (responses: WebsocAPIResponse[], sharedCourseKeys: Set) => { + const seenCourseKeys = new Set(sharedCourseKeys); + const orBlock: WebsocSchool[] = []; + const schoolMap = new Map(); + const deptMap = new Map(); + + for (const response of responses) { + for (const school of response.schools) { + for (const department of school.departments) { + for (const course of department.courses) { + const courseKey = getCourseKey(course.deptCode, course.courseNumber); + if (seenCourseKeys.has(courseKey)) continue; + + seenCourseKeys.add(courseKey); + + let orSchool = schoolMap.get(school.schoolName); + if (!orSchool) { + orSchool = { ...school, departments: [] }; + orBlock.push(orSchool); + schoolMap.set(school.schoolName, orSchool); + } + + const deptKey = `${school.schoolName}::${department.deptCode}`; + let orDepartment = deptMap.get(deptKey); + if (!orDepartment) { + orDepartment = { ...department, courses: [] }; + orSchool.departments.push(orDepartment); + deptMap.set(deptKey, orDepartment); + } + + orDepartment.courses.push(course); + } + } + } + } + + return orBlock; +}; + +export async function queryManualSearchCourses(params: Record) { + const selectedGEs = getSelectedGEs(params.ge); + + if (selectedGEs.length <= 1) { + return { + response: await queryWebsoc(params), + sharedCourseKeys: new Set(), + andSchoolCount: 0, + }; + } + + const responses = await Promise.all(selectedGEs.map((ge) => queryWebsoc({ ...params, ge }))); + const [firstResponse] = responses; + const sharedCourseKeys = getSharedCourseKeys(responses); + const andCourses = buildAnd(firstResponse, sharedCourseKeys); + const orCourses = buildOr(responses, sharedCourseKeys); + + return { + response: { + ...firstResponse, + schools: [...andCourses, ...orCourses], + }, + sharedCourseKeys, + andSchoolCount: andCourses.length, + }; +} + +export const getMultiGeCourseKey = getCourseKey; diff --git a/apps/antalmanac/src/lib/utils.ts b/apps/antalmanac/src/lib/utils.ts index 91fd4b5e3..d0bd7fa52 100644 --- a/apps/antalmanac/src/lib/utils.ts +++ b/apps/antalmanac/src/lib/utils.ts @@ -33,6 +33,16 @@ export function getErrorMessage(e: unknown) { return e instanceof Error ? e.message : String(e); } +export function replaceUrlSearchParams(update: (params: URLSearchParams) => void) { + const url = new URL(window.location.href); + const params = new URLSearchParams(url.search); + update(params); + + const query = params.toString(); + const nextUrl = `${url.pathname}${query ? `?${query}` : ''}${url.hash}`; + history.replaceState({ url: 'url' }, 'url', nextUrl); +} + export const safeUnreachableCase = (v: never, retVal?: T): T | undefined => { // if this code is running, v didn't turn out to be `never` after all, so tell TS that const castedV = v as unknown;