diff --git a/apps/backend/src/modules/catalog/controller.ts b/apps/backend/src/modules/catalog/controller.ts index 736aae9ad..8d482db87 100644 --- a/apps/backend/src/modules/catalog/controller.ts +++ b/apps/backend/src/modules/catalog/controller.ts @@ -25,13 +25,13 @@ import { } from "@repo/common/models"; import { getFields, hasFieldPath } from "../../utils/graphql"; -import { searchSemantic } from "../semantic-search/client"; import { formatClass, formatSection } from "../class/formatter"; import type { ClassModule } from "../class/generated-types/module-types"; import { formatCourse } from "../course/formatter"; import { formatEnrollment } from "../enrollment/formatter"; import type { EnrollmentModule } from "../enrollment/generated-types/module-types"; import type { GradeDistributionModule } from "../grade-distribution/generated-types/module-types"; +import { searchSemantic } from "../semantic-search/client"; import { getCachedCatalog, getSearchIndex } from "./catalog-cache"; export interface CatalogQueryParams { @@ -197,6 +197,30 @@ const getCatalogWithSemanticSearch = async ({ return { results, totalCount }; }; +const VIEWCOUNT_BOOST = 0.05; + +// Given fuzzy search returns, assign a score to each item and sort by viewCount +const scoreAndSort = ( + items: ICatalogClassItem[], + scoreMap: Map +): ICatalogClassItem[] => { + const maxViewCount = Math.max(...items.map((i) => i.viewCount ?? 0), 1); + + return [...items].sort((a, b) => { + const fuzzyA = scoreMap.get(a) ?? 1; + const fuzzyB = scoreMap.get(b) ?? 1; + const viewA = + (Math.log((a.viewCount ?? 0) + 1) / Math.log(maxViewCount + 1)) * + VIEWCOUNT_BOOST; + const viewB = + (Math.log((b.viewCount ?? 0) + 1) / Math.log(maxViewCount + 1)) * + VIEWCOUNT_BOOST; + const scoreA = fuzzyA - viewA; + const scoreB = fuzzyB - viewB; + return scoreA - scoreB; + }); +}; + const getCatalogWithSearch = async ({ year, semester, @@ -214,10 +238,14 @@ const getCatalogWithSearch = async ({ }) => { const index = await getSearchIndex(year, semester); const hits = index.search(searchTerm); + const scoreMap = new Map(); + for (const hit of hits) scoreMap.set(hit.item, hit.score ?? 1); const items = hits.map((r) => r.item); + const filtered = applyInMemoryFilters(items, filters); const totalCount = filtered.length; - const results = filtered.slice(skip, skip + limit); + const reranked = scoreAndSort(filtered, scoreMap); + const results = reranked.slice(skip, skip + limit); return { results, totalCount }; }; diff --git a/apps/frontend/src/app/Catalog/index.tsx b/apps/frontend/src/app/Catalog/index.tsx index f36407758..4de6565ca 100644 --- a/apps/frontend/src/app/Catalog/index.tsx +++ b/apps/frontend/src/app/Catalog/index.tsx @@ -29,6 +29,7 @@ import { Semester, } from "@/lib/generated/graphql"; import { RecentType, addRecent, getRecents } from "@/lib/recent"; +import { saveCourseClick } from "@/lib/recent"; import { compareCollectionsByBookmarksOrder } from "@/utils/collections"; import styles from "./Catalog.module.scss"; @@ -508,9 +509,11 @@ export default function Catalog() { subject: string, courseNumber: string, number: string, - sessionId: string + sessionId: string, + searchQuery: string ) => { if (!term) return; + saveCourseClick(`${subject}${courseNumber}`, searchQuery); setCatalogDrawerOpen(false); // Close drawer when selecting a class diff --git a/apps/frontend/src/app/GradTrak/Dashboard/LabelMenu/index.tsx b/apps/frontend/src/app/GradTrak/Dashboard/LabelMenu/index.tsx index 42da90636..aec5da795 100644 --- a/apps/frontend/src/app/GradTrak/Dashboard/LabelMenu/index.tsx +++ b/apps/frontend/src/app/GradTrak/Dashboard/LabelMenu/index.tsx @@ -5,9 +5,9 @@ import { Plus, Trash } from "iconoir-react"; import { Button, Color, Dialog, Flex, IconButton, Input } from "@repo/theme"; import ColorSelector from "@/components/ColorSelector"; +import { DeleteScheduleDialog } from "@/components/ScheduleCard/DeleteScheduleDialog"; import { ILabel } from "@/lib/api"; -import { DeleteScheduleDialog } from "@/components/ScheduleCard/DeleteScheduleDialog"; import styles from "./LabelMenu.module.scss"; type LabelMenuProps = { diff --git a/apps/frontend/src/components/ClassBrowser/List/index.tsx b/apps/frontend/src/components/ClassBrowser/List/index.tsx index 01eabdd86..269e0c849 100644 --- a/apps/frontend/src/components/ClassBrowser/List/index.tsx +++ b/apps/frontend/src/components/ClassBrowser/List/index.tsx @@ -63,7 +63,8 @@ interface ListProps { subject: string, courseNumber: string, number: string, - sessionId: string + sessionId: string, + searchQuery: string ) => void; } @@ -108,6 +109,8 @@ export default function List({ onSelect }: ListProps) { const recentlyViewedListRef = useRef(null); const recentlyViewedMeasureRef = useRef(null); + const { query } = useLayoutContext(); + useEffect(() => { setResolvedSessionIds({}); }, [year, semester]); @@ -285,7 +288,8 @@ export default function List({ onSelect }: ListProps) { selected.subject, selected.courseNumber, selected.number, - selected.sessionId + selected.sessionId, + query ); }; @@ -336,7 +340,8 @@ export default function List({ onSelect }: ListProps) { recentClass.subject, recentClass.courseNumber, recentClass.number, - recentClass.sessionId + recentClass.sessionId, + query ); }} > diff --git a/apps/frontend/src/components/ClassBrowser/hooks/useCatalogQuery.ts b/apps/frontend/src/components/ClassBrowser/hooks/useCatalogQuery.ts index c8e8b3894..2a286f739 100644 --- a/apps/frontend/src/components/ClassBrowser/hooks/useCatalogQuery.ts +++ b/apps/frontend/src/components/ClassBrowser/hooks/useCatalogQuery.ts @@ -138,7 +138,10 @@ export default function useCatalogQuery({ ); // Server-side catalog query (always requests first page) - const { data, loading, error, fetchMore } = useQuery(GET_CATALOG_SEARCH, { + const { data, loading, error, fetchMore } = useQuery< + GetCatalogSearchQuery, + GetCatalogSearchQueryVariables + >(GET_CATALOG_SEARCH, { variables: { ...catalogQueryVariables, page: 1, @@ -215,7 +218,7 @@ export default function useCatalogQuery({ const isFirstPageLoading = loading && localPage === 1 && !isLoadingNextPage; const semanticError = semanticSearch && error - ? (error.graphQLErrors?.[0]?.message ?? error.message ?? "AI search failed") + ? (error.message ?? "AI search failed") // pre-existing error, not relatd to this pr : null; return { diff --git a/apps/frontend/src/components/ClassBrowser/index.tsx b/apps/frontend/src/components/ClassBrowser/index.tsx index d07fda0ab..390b55d55 100644 --- a/apps/frontend/src/components/ClassBrowser/index.tsx +++ b/apps/frontend/src/components/ClassBrowser/index.tsx @@ -18,7 +18,8 @@ interface ClassBrowserProps { subject: string, courseNumber: string, number: string, - sessionId: string + sessionId: string, + searchQuery: string ) => void; forceMode?: CatalogLayoutMode; semester: Semester; diff --git a/apps/frontend/src/lib/api/catalog.ts b/apps/frontend/src/lib/api/catalog.ts index 8cf0da05f..265037a15 100644 --- a/apps/frontend/src/lib/api/catalog.ts +++ b/apps/frontend/src/lib/api/catalog.ts @@ -17,6 +17,7 @@ export const GET_CATALOG_SEARCH = gql` $page: Int $pageSize: Int $semanticSearch: Boolean + $recentClicks: [RecentClick] ) { catalogSearch( year: $year @@ -28,6 +29,7 @@ export const GET_CATALOG_SEARCH = gql` page: $page pageSize: $pageSize semanticSearch: $semanticSearch + recentClicks: $recentClicks ) { results { year diff --git a/apps/frontend/src/lib/recent.ts b/apps/frontend/src/lib/recent.ts index dddfcef82..88e0b66b5 100644 --- a/apps/frontend/src/lib/recent.ts +++ b/apps/frontend/src/lib/recent.ts @@ -8,6 +8,7 @@ export enum RecentType { CatalogTerm = "recent-catalog-term", GradesPage = "recent-grades-page", EnrollmentPage = "recent-enrollment-page", + CourseClick = "recent-course-click", } const MaxLength = { @@ -17,6 +18,7 @@ const MaxLength = { [RecentType.CatalogTerm]: 1, [RecentType.GradesPage]: 1, [RecentType.EnrollmentPage]: 1, + [RecentType.CourseClick]: 50, }; interface RecentClass { @@ -56,6 +58,12 @@ interface RecentPageUrl { timestamp: number; } +interface RecentCourseClick { + searchTerm: string; + courseNumber: string; + timestamp: number; +} + export type Recent = T extends RecentType.Class ? RecentClass : T extends RecentType.Schedule @@ -68,7 +76,9 @@ export type Recent = T extends RecentType.Class ? RecentPageUrl : T extends RecentType.EnrollmentPage ? RecentPageUrl - : never; + : T extends RecentType.CourseClick + ? RecentCourseClick + : never; export const getRecents = ( type: T, @@ -204,3 +214,15 @@ export const getPageUrl = ( const saved = getRecents(type) as RecentPageUrl[]; return saved[0]?.url ?? null; }; + +export const saveCourseClick = (courseNumber: string, searchTerm: string) => { + addRecent(RecentType.CourseClick, { + searchTerm, + courseNumber, + timestamp: Date.now(), + }); +}; + +export const getCourseClicks = (): RecentCourseClick[] => { + return getRecents(RecentType.CourseClick) as RecentCourseClick[]; +}; diff --git a/packages/gql-typedefs/catalog.ts b/packages/gql-typedefs/catalog.ts index c06f44632..d6937a01d 100644 --- a/packages/gql-typedefs/catalog.ts +++ b/packages/gql-typedefs/catalog.ts @@ -34,6 +34,12 @@ export const catalogTypeDef = gql` online: Boolean } + input RecentClick { + courseNumber: String! + searchTerm: String! + timestamp: Float! + } + type CatalogMeeting { days: [Boolean!] startTime: String @@ -213,6 +219,7 @@ export const catalogTypeDef = gql` page: Int pageSize: Int semanticSearch: Boolean + recentClicks: [RecentClick] ): CatalogResult! catalogClassIdentities(