Skip to content
32 changes: 30 additions & 2 deletions apps/backend/src/modules/catalog/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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, number>
): 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,
Expand All @@ -214,10 +238,14 @@ const getCatalogWithSearch = async ({
}) => {
const index = await getSearchIndex(year, semester);
const hits = index.search(searchTerm);
const scoreMap = new Map<ICatalogClassItem, number>();
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 };
};
Expand Down
5 changes: 4 additions & 1 deletion apps/frontend/src/app/Catalog/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
11 changes: 8 additions & 3 deletions apps/frontend/src/components/ClassBrowser/List/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,8 @@ interface ListProps {
subject: string,
courseNumber: string,
number: string,
sessionId: string
sessionId: string,
searchQuery: string
) => void;
}

Expand Down Expand Up @@ -108,6 +109,8 @@ export default function List({ onSelect }: ListProps) {
const recentlyViewedListRef = useRef<HTMLDivElement>(null);
const recentlyViewedMeasureRef = useRef<HTMLDivElement>(null);

const { query } = useLayoutContext();

useEffect(() => {
setResolvedSessionIds({});
}, [year, semester]);
Expand Down Expand Up @@ -285,7 +288,8 @@ export default function List({ onSelect }: ListProps) {
selected.subject,
selected.courseNumber,
selected.number,
selected.sessionId
selected.sessionId,
query
);
};

Expand Down Expand Up @@ -336,7 +340,8 @@ export default function List({ onSelect }: ListProps) {
recentClass.subject,
recentClass.courseNumber,
recentClass.number,
recentClass.sessionId
recentClass.sessionId,
query
);
}}
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,10 @@ export default function useCatalogQuery({
);

// Server-side catalog query (always requests first page)
const { data, loading, error, fetchMore } = useQuery<GetCatalogSearchQuery, GetCatalogSearchQueryVariables>(GET_CATALOG_SEARCH, {
const { data, loading, error, fetchMore } = useQuery<
GetCatalogSearchQuery,
GetCatalogSearchQueryVariables
>(GET_CATALOG_SEARCH, {
variables: {
...catalogQueryVariables,
page: 1,
Expand Down Expand Up @@ -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 {
Expand Down
3 changes: 2 additions & 1 deletion apps/frontend/src/components/ClassBrowser/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ interface ClassBrowserProps {
subject: string,
courseNumber: string,
number: string,
sessionId: string
sessionId: string,
searchQuery: string
) => void;
forceMode?: CatalogLayoutMode;
semester: Semester;
Expand Down
2 changes: 2 additions & 0 deletions apps/frontend/src/lib/api/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export const GET_CATALOG_SEARCH = gql`
$page: Int
$pageSize: Int
$semanticSearch: Boolean
$recentClicks: [RecentClick]
) {
catalogSearch(
year: $year
Expand All @@ -28,6 +29,7 @@ export const GET_CATALOG_SEARCH = gql`
page: $page
pageSize: $pageSize
semanticSearch: $semanticSearch
recentClicks: $recentClicks
) {
results {
year
Expand Down
24 changes: 23 additions & 1 deletion apps/frontend/src/lib/recent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -17,6 +18,7 @@ const MaxLength = {
[RecentType.CatalogTerm]: 1,
[RecentType.GradesPage]: 1,
[RecentType.EnrollmentPage]: 1,
[RecentType.CourseClick]: 50,
};

interface RecentClass {
Expand Down Expand Up @@ -56,6 +58,12 @@ interface RecentPageUrl {
timestamp: number;
}

interface RecentCourseClick {
searchTerm: string;
courseNumber: string;
timestamp: number;
}

export type Recent<T extends RecentType> = T extends RecentType.Class
? RecentClass
: T extends RecentType.Schedule
Expand All @@ -68,7 +76,9 @@ export type Recent<T extends RecentType> = T extends RecentType.Class
? RecentPageUrl
: T extends RecentType.EnrollmentPage
? RecentPageUrl
: never;
: T extends RecentType.CourseClick
? RecentCourseClick
: never;

export const getRecents = <T extends RecentType>(
type: T,
Expand Down Expand Up @@ -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[];
};
7 changes: 7 additions & 0 deletions packages/gql-typedefs/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ export const catalogTypeDef = gql`
online: Boolean
}

input RecentClick {
courseNumber: String!
searchTerm: String!
timestamp: Float!
}

type CatalogMeeting {
days: [Boolean!]
startTime: String
Expand Down Expand Up @@ -213,6 +219,7 @@ export const catalogTypeDef = gql`
page: Int
pageSize: Int
semanticSearch: Boolean
recentClicks: [RecentClick]
): CatalogResult!

catalogClassIdentities(
Expand Down
Loading