From 137200a45680fc915214a8aff9c8545a1969e160 Mon Sep 17 00:00:00 2001 From: Matthew Rowland Date: Thu, 7 Nov 2024 14:24:15 -0800 Subject: [PATCH] feat: Clean up getCatalog and prepare for server-side filtering, sorting, and pagination to improve client performance --- apps/backend/package.json | 1 + .../backend/src/modules/catalog/controller.ts | 629 +++++++++++++++--- apps/backend/src/modules/catalog/resolver.ts | 4 +- .../src/modules/catalog/typedefs/catalog.ts | 2 +- apps/backend/src/utils/graphql.ts | 32 +- apps/frontend/schema.graphql | 2 +- .../components/ClassBrowser/List/index.tsx | 6 +- .../src/components/ClassBrowser/index.tsx | 30 +- apps/frontend/src/lib/api/classes.ts | 48 +- package-lock.json | 1 + 10 files changed, 581 insertions(+), 174 deletions(-) diff --git a/apps/backend/package.json b/apps/backend/package.json index 6ee0eefa6..fb0171080 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -49,6 +49,7 @@ "dotenv": "^16.4.5", "express": "^4.19.2", "express-session": "^1.18.0", + "fuse.js": "^7.0.0", "graphql": "^16.9.0", "graphql-modules": "^2.3.0", "graphql-type-json": "^0.3.2", diff --git a/apps/backend/src/modules/catalog/controller.ts b/apps/backend/src/modules/catalog/controller.ts index be819c3f4..72b8cd6a6 100644 --- a/apps/backend/src/modules/catalog/controller.ts +++ b/apps/backend/src/modules/catalog/controller.ts @@ -1,161 +1,578 @@ +import Fuse from "fuse.js"; import { GraphQLResolveInfo } from "graphql"; import { ClassModel, CourseModel, CourseType, - GradeModel, - GradeType, + GradeDistributionModel, + GradeDistributionType, SectionModel, + SectionType, + TermModel, } from "@repo/common"; -import { getCourseKey, getCsCourseId } from "../../utils/course"; -import { getChildren } from "../../utils/graphql"; +import { getFields } from "../../utils/graphql"; import { formatClass, formatSection } from "../class/formatter"; +import { ClassModule } from "../class/generated-types/module-types"; import { formatCourse } from "../course/formatter"; -import { CatalogModule } from "./generated-types/module-types"; +import { + getAverageGrade, + getDistribution, +} from "../grade-distribution/controller"; +import { GradeDistributionModule } from "../grade-distribution/generated-types/module-types"; + +const getId = (identifiers?: CourseType["identifiers"]) => { + return identifiers?.find((identifier) => identifier.type === "cs-course-id") + ?.id; +}; -function matchCsCourseId(id: any) { - return { - $elemMatch: { - type: "cs-course-id", - id, - }, +export const getIndex = (classes: ClassModule.Class[]) => { + const list = classes.map((_class) => { + const { title, subject, number } = _class.course; + + // For prefixed courses, prefer the number and add an abbreviation with the prefix + const containsPrefix = /^[a-zA-Z].*/.test(number); + const alternateNumber = number.slice(1); + + const term = subject.toLowerCase(); + + const alternateNames = subjects[term]?.abbreviations.reduce( + (acc, abbreviation) => { + // Add alternate names for abbreviations + const abbreviations = [ + `${abbreviation}${number}`, + `${abbreviation} ${number}`, + ]; + + if (containsPrefix) { + abbreviations.push( + `${abbreviation}${alternateNumber}`, + `${abbreviation} ${alternateNumber}` + ); + } + + return [...acc, ...abbreviations]; + }, + // Add alternate names + containsPrefix + ? [ + `${subject}${number}`, + `${subject} ${alternateNumber}`, + `${subject}${alternateNumber}`, + ] + : [`${subject}${number}`] + ); + + return { + title: _class.title ?? title, + // subject, + // number, + name: `${subject} ${number}`, + alternateNames, + }; + }); + + // Attempt to increase performance by dropping unnecessary fields + const options = { + includeScore: true, + // ignoreLocation: true, + threshold: 0.25, + keys: [ + // { name: "number", weight: 1.2 }, + "name", + "title", + { + name: "alternateNames", + weight: 2, + }, + // { name: "subject", weight: 1.5 }, + ], + // TODO: Fuse types are wrong for sortFn + // eslint-disable-next-line @typescript-eslint/no-explicit-any + // sortFn: (a: any, b: any) => { + // // First, sort by score + // if (a.score - b.score) return a.score - b.score; + + // // Otherwise, sort by number + // return a.item[0].v.toLowerCase().localeCompare(b.item[0].v.toLowerCase()); + // }, }; + + return new Fuse(list, options); +}; + +interface Subject { + abbreviations: string[]; + name: string; } -// TODO: Grade distributions +// TODO: https://guide.berkeley.edu/courses + +export const subjects: Record = { + astron: { + abbreviations: ["astro"], + name: "Astronomy", + }, + compsci: { + abbreviations: ["cs", "comp sci", "computer science"], + name: "Computer Science", + }, + mcellbi: { + abbreviations: ["mcb"], + name: "Molecular and Cell Biology", + }, + nusctx: { + abbreviations: ["nutrisci"], + name: "Nutritional Sciences and Toxicology", + }, + bioeng: { + abbreviations: ["bioe", "bio e", "bio p", "bio eng"], + name: "Bioengineering", + }, + biology: { + abbreviations: ["bio"], + name: "Biology", + }, + civeng: { + abbreviations: ["cive", "civ e", "civ eng"], + name: "Civil and Environmental Engineering", + }, + chmeng: { + abbreviations: ["cheme", "chm eng"], + name: "Chemical Engineering", + }, + classic: { + abbreviations: ["classics"], + name: "Classics", + }, + cogsci: { + abbreviations: ["cogsci"], + name: "Cognitive Science", + }, + colwrit: { + abbreviations: ["college writing", "col writ"], + name: "College Writing", + }, + comlit: { + abbreviations: ["complit", "com lit"], + name: "Comparative Literature", + }, + cyplan: { + abbreviations: ["cy plan", "cp"], + name: "City and Regional Planning", + }, + desinv: { + abbreviations: ["des inv", "design"], + name: "Design Innovation", + }, + deveng: { + abbreviations: ["dev eng"], + name: "Development Engineering", + }, + devstd: { + abbreviations: ["dev std"], + name: "Development Studies", + }, + datasci: { + abbreviations: ["ds", "data", "data sci"], + name: "Data Science", + }, + data: { + abbreviations: ["ds", "data", "data sci"], + name: "Data Science, Undergraduate", + }, + ealang: { + abbreviations: ["ea lang"], + name: "East Asian Languages and Cultures", + }, + envdes: { + abbreviations: ["ed", "env des"], + name: "Environmental Design", + }, + eleng: { + abbreviations: ["ee", "electrical engineering", "el eng"], + name: "Electrical Engineering", + }, + eecs: { + abbreviations: ["eecs"], + name: "Electrical Engineering and Computer Sciences", + }, + eneres: { + abbreviations: ["erg", "er", "ene,res"], + name: "Energy and Resources Group", + }, + engin: { + abbreviations: ["e", "engineering"], + name: "Engineering", + }, + envsci: { + abbreviations: ["env sci"], + name: "Environmental Sciences", + }, + ethstd: { + abbreviations: ["eth std"], + name: "Ethnic Studies", + }, + geog: { + abbreviations: ["geology", "geo"], + name: "Geography", + }, + hinurd: { + abbreviations: ["hin urd", "hin-urd"], + name: "Hindi-Urdu", + }, + integbi: { + abbreviations: ["ib"], + name: "Integrative Biology", + }, + indeng: { + abbreviations: ["ie", "ieor", "ind eng"], + name: "Industrial Engineering and Operations Research", + }, + linguis: { + abbreviations: ["ling"], + name: "Linguistics", + }, + "l&s": { + abbreviations: ["l & s", "ls", "lns"], + name: "Letters and Science", + }, + indones: { + abbreviations: ["indonesian"], + name: "Indonesian", + }, + matsci: { + abbreviations: ["mat sci", "ms", "mse"], + name: "Materials Science and Engineering", + }, + meceng: { + abbreviations: ["mec eng", "meche", "mech e", "me"], + name: "Mechanical Engineering", + }, + medst: { + abbreviations: ["med st"], + name: "Medical Studies", + }, + mestu: { + abbreviations: ["me stu", "middle eastern studies"], + name: "Middle Eastern Studies", + }, + milaff: { + abbreviations: ["mil aff"], + name: "Military Affairs", + }, + milsci: { + abbreviations: ["mil sci"], + name: "Military Science", + }, + natamst: { + abbreviations: ["native american studies", "nat am st"], + name: "Native American Studies", + }, + neurosc: { + abbreviations: ["neurosci"], + name: "Neuroscience", + }, + nuceng: { + abbreviations: ["ne", "nuc eng"], + name: "Nuclear Engineering", + }, + mediast: { + abbreviations: ["media", "media st"], + name: "Media Studies", + }, + music: { + abbreviations: ["mus"], + name: "Music", + }, + pbhlth: { + abbreviations: ["pb hlth", "ph", "pub hlth", "public health"], + name: "Public Health", + }, + physed: { + abbreviations: ["pe", "phys ed"], + name: "Physical Education", + }, + polecon: { + abbreviations: ["poliecon"], + name: "Political Economy", + }, + philo: { + abbreviations: ["philosophy", "philos", "phil"], + name: "Philosophy", + }, + plantbi: { + abbreviations: ["pmb"], + name: "Plant and Microbial Biology", + }, + polsci: { + abbreviations: ["poli", "pol sci", "polisci", "poli sci", "ps"], + name: "Political Science", + }, + pubpol: { + abbreviations: ["pub pol", "pp", "public policy"], + name: "Public Policy", + }, + pubaff: { + abbreviations: ["pubaff", "public affaris"], + name: "Public Affairs", + }, + psych: { + abbreviations: ["psychology"], + name: "Psychology", + }, + rhetor: { + abbreviations: ["rhetoric"], + name: "Rhetoric", + }, + sasian: { + abbreviations: ["s asian"], + name: "South Asian Studies", + }, + seasian: { + abbreviations: ["se asian"], + name: "Southeast Asian Studies", + }, + stat: { + abbreviations: ["stats"], + name: "Statistics", + }, + theater: { + abbreviations: ["tdps"], + name: "Theater, Dance, and Performance Studies", + }, + ugba: { + abbreviations: ["haas"], + name: "Undergraduate Business Administration", + }, + vietnms: { + abbreviations: ["vietnamese"], + name: "Vietnamese", + }, + vissci: { + abbreviations: ["vis sci"], + name: "Vision Science", + }, + visstd: { + abbreviations: ["vis std"], + name: "Visual Studies", + }, +}; + +// TODO: Pagination, filtering export const getCatalog = async ( year: number, semester: string, - info: GraphQLResolveInfo + info: GraphQLResolveInfo, + query?: string | null ) => { + const name = `${year} ${semester}`; + + const term = await TermModel.findOne({ + name, + }).lean(); + + if (!term) throw new Error("Invalid term"); + + /** + * TODO: + * Basic pagination can be introduced by using skip and limit + * However, because filtering requires access to all three collections, + * we cannot paginate the MongoDB queries themselves while filtering + * course or section fields + * + * We can optimize filtering by applying skip and limit to the classes + * query when only filtering by class fields, and then fall back to + * in-memory filtering for fields from courses and sections + */ + + // Fetch available classes for the term const classes = await ClassModel.find({ - "session.term.name": `${year} ${semester}`, + "session.term.name": name, anyPrintInScheduleOfClasses: true, }).lean(); - if (classes.length === 0) return []; + // Filtering by identifiers reduces the amount of data returned for courses and sections + const courseIds = Array.from( + classes.reduce((accumulator, _class) => { + const courseId = getId(_class.course?.identifiers); + if (!courseId) return accumulator; - const csCourseIds = new Set( - classes.map((c) => getCsCourseId(c.course as CourseType)) + accumulator.add(courseId); + return accumulator; + }, new Set()) ); + // Fetch available courses for the term const courses = await CourseModel.find({ - identifiers: matchCsCourseId({ $in: Array.from(csCourseIds) }), - }) - .sort({ - "classSubjectArea.code": 1, - "catalogNumber.formatted": 1, - fromDate: -1, - }) - .lean(); - - /* Map grades to course keys for easy lookup */ - const gradesMap: { [key: string]: GradeType[] } = {}; - courses.forEach((c) => (gradesMap[getCourseKey(c)] = [])); - - const children = getChildren(info); - - if (children.includes("gradeAverage")) { - const grades = await GradeModel.find( - // No filters because an appropriately large filter is actually significantly slower than no filter. - {}, - { - CourseSubjectShortNm: 1, - CourseNumber: 1, - GradeNm: 1, - EnrollmentCnt: 1, - } - ).lean(); - - for (const g of grades) { - const key = `${g.CourseSubjectShortNm as string} ${ - g.CourseNumber as string - }`; - - if (key in gradesMap) { - gradesMap[key].push(g); - } - } - } + identifiers: { + // The bottleneck seems to be the amount of data we are fetching and not the query itself + $elemMatch: { + type: "cs-course-id", + id: { $in: Array.from(courseIds) }, + }, + }, + printInCatalog: true, + }).lean(); + // Fetch available sections for the term const sections = await SectionModel.find({ - "class.course.identifiers": matchCsCourseId({ - $in: Array.from(csCourseIds), - }), - "class.session.term.name": `${year} ${semester}`, - "association.primary": true, + "class.session.term.name": name, + "class.course.identifiers": { + // The bottleneck seems to be the amount of data we are fetching and not the query itself + $elemMatch: { + type: "cs-course-id", + id: { $in: Array.from(courseIds) }, + }, + }, + printInScheduleOfClasses: true, }).lean(); - const catalog: any = {}; + const parsedGradeDistributions = {} as Record< + string, + GradeDistributionModule.GradeDistribution + >; - for (const c of courses) { - // const key = getCourseKey(c); - const id = getCsCourseId(c); + const children = getFields(info.fieldNodes); + const includesGradeDistribution = children.includes("gradeDistribution"); - // skip duplicates - if (id in catalog) continue; + if (includesGradeDistribution) { + const sectionIds = sections.map((section) => section.id); - catalog[id] = { - ...formatCourse(c), - classes: [], - gradeDistribution: { - average: null, + const gradeDistributions = await GradeDistributionModel.find({ + // The bottleneck seems to be the amount of data we are fetching and not the query itself + classNumber: { $in: sectionIds }, + }).lean(); + + const reducedGradeDistributions = gradeDistributions.reduce( + (accumulator, gradeDistribution) => { + const courseId = `${gradeDistribution.subject} ${gradeDistribution.courseNumber}`; + const sectionId = gradeDistribution.classNumber; + + accumulator[courseId] = accumulator[courseId] + ? [...accumulator[courseId], gradeDistribution] + : [gradeDistribution]; + accumulator[sectionId] = accumulator[sectionId] + ? [...accumulator[sectionId], gradeDistribution] + : [gradeDistribution]; + + return accumulator; }, - }; - } + {} as Record + ); - for (const c of classes) { - const id = getCsCourseId(c.course as CourseType); + const entries = Object.entries(reducedGradeDistributions); - if (!(id in catalog)) { - console.warn( - `Class ${c.course?.subjectArea?.code} ${c.course?.catalogNumber?.formatted}` + - ` has a course id ${id} that doesn't exist for the ${semester} ${year} term.` - ); + for (const [key, value] of entries) { + const distribution = getDistribution(value); - continue; + parsedGradeDistributions[key] = { + average: getAverageGrade(distribution), + distribution, + } as GradeDistributionModule.GradeDistribution; } - - catalog[id].classes.push(formatClass(c)); } - for (const s of sections) { - if (!s.class) continue; + // Turn courses into a map to decrease time complexity for filtering + const reducedCourses = courses.reduce( + (accumulator, course) => { + const id = getId(course?.identifiers); + if (!id) return accumulator; - const id = getCsCourseId(s.class.course as CourseType); + accumulator[id] = course; + return accumulator; + }, + {} as Record + ); - if (!(id in catalog)) { - console.warn( - `Section ${s.class.course?.subjectArea?.code} ${s.class.course?.catalogNumber?.formatted} has a course id ${id} that doesn't exist for the ${semester} ${year} term.` - ); + // Turn sections into a map to decrease time complexity for filtering + const reducedSections = sections.reduce( + (accumulator, section) => { + const courseId = getId(section.class?.course?.identifiers); + const number = section.class?.number; - continue; - } + const id = `${courseId}-${number}`; + if (!id) return accumulator; - const index = catalog[id].classes.findIndex( - (c: any) => c.number === s.class?.number - ); + accumulator[id] = accumulator[id] + ? [...accumulator[id], section] + : [section]; - if (index === -1) continue; + return accumulator; + }, + {} as Record + ); - const primarySection = formatSection(s); + const reducedClasses = classes.reduce((accumulator, _class) => { + const courseId = getId(_class.course?.identifiers); + if (!courseId) return accumulator; - if (!primarySection.ccn) continue; + const course = reducedCourses[courseId]; + if (!course) return accumulator; - catalog[id].classes[index].primarySection = primarySection; - } + const sections = reducedSections[`${courseId}-${_class.number}`]; + if (!sections) return accumulator; - for (const id in catalog) { - catalog[id].classes = catalog[id].classes.filter( - (c: any) => c.primarySection?.ccn - ); + const index = sections.findIndex((section) => section.association?.primary); + if (index === -1) return accumulator; - if (catalog[id].classes.length === 0) { - delete catalog[id]; + const formattedPrimarySection = formatSection(sections.splice(index, 1)[0]); + const formattedSections = sections.map(formatSection); + + const formattedCourse = formatCourse( + course + ) as unknown as ClassModule.Course; + + // Add grade distribution to course + if (includesGradeDistribution) { + const key = `${course.subjectArea?.code} ${course.catalogNumber?.formatted}`; + const gradeDistribution = parsedGradeDistributions[key]; + + // Fall back to an empty grade distribution to prevent resolving the field again + formattedCourse.gradeDistribution = gradeDistribution ?? { + average: null, + distribution: [], + }; } + + const formattedClass = { + ...formatClass(_class), + primarySection: formattedPrimarySection, + sections: formattedSections, + course: formattedCourse, + } as unknown as ClassModule.Class; + + // Add grade distribution to class + if (includesGradeDistribution) { + const sectionId = formattedPrimarySection.ccn; + + // Fall back to an empty grade distribution to prevent resolving the field again + const gradeDistribution = parsedGradeDistributions[sectionId] ?? { + average: null, + distribution: [], + }; + + formattedClass.gradeDistribution = gradeDistribution; + } + + accumulator.push(formattedClass); + return accumulator; + }, [] as ClassModule.Class[]); + + query = query?.trim(); + + if (query) { + const index = getIndex(reducedClasses); + + // TODO: Limit query because Fuse performance decreases linearly by + // n (field length) * m (pattern length) * l (maximum Levenshtein distance) + const filteredClasses = index + .search(query) + .map(({ refIndex }) => reducedClasses[refIndex]); + + return filteredClasses; } - return Object.values(catalog) as CatalogModule.Course[]; + return reducedClasses; }; diff --git a/apps/backend/src/modules/catalog/resolver.ts b/apps/backend/src/modules/catalog/resolver.ts index 4dcec99d7..c8687d1dd 100644 --- a/apps/backend/src/modules/catalog/resolver.ts +++ b/apps/backend/src/modules/catalog/resolver.ts @@ -3,11 +3,11 @@ import { CatalogModule } from "./generated-types/module-types"; const resolvers: CatalogModule.Resolvers = { Query: { - catalog: async (_, { year, semester }, __, info) => { + catalog: async (_, { year, semester, query }, __, info) => { // const cacheControl = cacheControlFromInfo(info); // cacheControl.setCacheHint({ maxAge: 300 }); - return await getCatalog(year, semester, info); + return await getCatalog(year, semester, info, query); }, }, }; diff --git a/apps/backend/src/modules/catalog/typedefs/catalog.ts b/apps/backend/src/modules/catalog/typedefs/catalog.ts index 68789cd86..64cc791e4 100644 --- a/apps/backend/src/modules/catalog/typedefs/catalog.ts +++ b/apps/backend/src/modules/catalog/typedefs/catalog.ts @@ -2,6 +2,6 @@ import { gql } from "graphql-tag"; export default gql` type Query { - catalog(year: Int!, semester: Semester!): [Course!]! + catalog(year: Int!, semester: Semester!, query: String): [Class!]! } `; diff --git a/apps/backend/src/utils/graphql.ts b/apps/backend/src/utils/graphql.ts index 94738676c..e2ee7623d 100644 --- a/apps/backend/src/utils/graphql.ts +++ b/apps/backend/src/utils/graphql.ts @@ -1,12 +1,20 @@ -import { GraphQLResolveInfo } from "graphql"; - -/** - * Gets the subfields of the current GraphQL resolver based on its info object. - * Caution: This function is generated by copilot - */ -export function getChildren(info: GraphQLResolveInfo): string[] { - const children = info.fieldNodes[0].selectionSet?.selections; - return children != undefined - ? children.map((child: any) => child.name.value) - : []; -} +import { SelectionNode } from "graphql"; + +// Recursively retrieve all fields from a GraphQL query +export const getFields = (nodes: readonly SelectionNode[]): string[] => { + return Array.from( + new Set( + nodes.flatMap((node) => { + if (node.kind !== "Field") return []; + + const value = node.name.value; + + return node.selectionSet + ? [value, ...getFields(node.selectionSet.selections)] + : [value]; + }) + ) + ); +}; + +// TODO: Middleware to detect expensive queries diff --git a/apps/frontend/schema.graphql b/apps/frontend/schema.graphql index ceb9cb8b5..3863beae7 100644 --- a/apps/frontend/schema.graphql +++ b/apps/frontend/schema.graphql @@ -29,7 +29,7 @@ type Query { givenName: String familyName: String ): GradeDistribution! - catalog(year: Int!, semester: Semester!): [Course!]! + catalog(year: Int!, semester: Semester!): [Class!]! ping: String! @deprecated(reason: "test") schedules: [Schedule] schedule(id: ID!): Schedule diff --git a/apps/frontend/src/components/ClassBrowser/List/index.tsx b/apps/frontend/src/components/ClassBrowser/List/index.tsx index 7687ab1bd..459f6aefb 100644 --- a/apps/frontend/src/components/ClassBrowser/List/index.tsx +++ b/apps/frontend/src/components/ClassBrowser/List/index.tsx @@ -84,7 +84,11 @@ export default function List({ onSelect }: ListProps) { key={key} ref={virtualizer.measureElement} onClick={() => - onSelect(_class.subject, _class.courseNumber, _class.number) + onSelect( + _class.course.subject, + _class.course.number, + _class.number + ) } /> ); diff --git a/apps/frontend/src/components/ClassBrowser/index.tsx b/apps/frontend/src/components/ClassBrowser/index.tsx index 7d117040a..80bc6f814 100644 --- a/apps/frontend/src/components/ClassBrowser/index.tsx +++ b/apps/frontend/src/components/ClassBrowser/index.tsx @@ -6,8 +6,8 @@ import { useSearchParams } from "react-router-dom"; import { Component, - GET_CLASSES, - GetClassesResponse, + GET_CATALOG, + GetCatalogResponse, IClass, Semester, } from "@/lib/api"; @@ -52,36 +52,14 @@ export default function ClassBrowser({ const [localOpen, setLocalOpen] = useState(false); const [localOnline, setLocalOnline] = useState(false); - const { data, loading } = useQuery(GET_CLASSES, { + const { data, loading } = useQuery(GET_CATALOG, { variables: { semester: currentSemester, year: currentYear, }, }); - const classes = useMemo( - () => - data?.catalog.reduce((acc, course) => { - // Map each class to minimal representation for filtering - const classes = course.classes.map( - (_class) => - ({ - ..._class, - course: { - subject: course.subject, - number: course.number, - title: course.title, - gradeDistribution: course.gradeDistribution, - academicCareer: course.academicCareer, - }, - }) as IClass - ); - - // Combine all classes into a single array - return [...acc, ...classes]; - }, [] as IClass[]) ?? [], - [data?.catalog] - ); + const classes = useMemo(() => data?.catalog ?? [], [data]); const query = useMemo( () => (persistent ? (searchParams.get("query") ?? "") : localQuery), diff --git a/apps/frontend/src/lib/api/classes.ts b/apps/frontend/src/lib/api/classes.ts index 49593720d..888a057c3 100644 --- a/apps/frontend/src/lib/api/classes.ts +++ b/apps/frontend/src/lib/api/classes.ts @@ -325,41 +325,39 @@ export const READ_CLASS = gql` } `; -export interface GetClassesResponse { - catalog: ICourse[]; +export interface GetCatalogResponse { + catalog: IClass[]; } -export const GET_CLASSES = gql` - query GetClasses($year: Int!, $semester: Semester!) { +export const GET_CATALOG = gql` + query GetCatalog($year: Int!, $semester: Semester!) { catalog(year: $year, semester: $semester) { - subject number title - gradeDistribution { - average + unitsMax + unitsMin + finalExam + gradingBasis + primarySection { + component + online + open + enrollCount + enrollMax + waitlistCount + waitlistMax + meetings { + days + } } - academicCareer - classes { + course { subject - courseNumber number title - unitsMax - unitsMin - finalExam - gradingBasis - primarySection { - component - online - open - enrollCount - enrollMax - waitlistCount - waitlistMax - meetings { - days - } + gradeDistribution { + average } + academicCareer } } } diff --git a/package-lock.json b/package-lock.json index e7f204471..0bce4831a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,6 +44,7 @@ "dotenv": "^16.4.5", "express": "^4.19.2", "express-session": "^1.18.0", + "fuse.js": "^7.0.0", "graphql": "^16.9.0", "graphql-modules": "^2.3.0", "graphql-type-json": "^0.3.2",