Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
a71f712
chore: enforce prettier styles in editorconfig
ap-1 Feb 3, 2025
98d509b
feat: add methods to clear individual fields
ap-1 Feb 3, 2025
30453d0
feat: create state and reducer for sorting
ap-1 Feb 3, 2025
f41448e
feat: add clearing functionality to fields
ap-1 Feb 3, 2025
8ad6c89
feat: add clear all button
ap-1 Feb 3, 2025
5cca729
feat: add sorting panel to sidebar
ap-1 Feb 3, 2025
e9f1b1a
style: reformat file
ap-1 Feb 3, 2025
d97e97e
fix: ensure dragging mutates state
ap-1 Feb 3, 2025
f225e69
chore: update dependencies
ap-1 Feb 3, 2025
7f3e0e1
chore: move dependencies to correct app
ap-1 Feb 3, 2025
e61ac52
feat: implement draggable and reorderable pills
ap-1 Feb 3, 2025
770fa01
fix: only reset sort type if it does not already exist
ap-1 Feb 3, 2025
592ac2d
refactor: allow targeting of sorts by option
ap-1 Feb 3, 2025
b844bc5
style: fix spacing issue
ap-1 Feb 3, 2025
af44e53
style: standardize pill text sizes
ap-1 Feb 3, 2025
4661646
feat: create sort by panel and selection
ap-1 Feb 3, 2025
0f4847a
style: use oxford comma
ap-1 Feb 3, 2025
8074b27
feat: include autogenerated file
ap-1 Feb 3, 2025
79951c8
chore: add correct metadata
ap-1 Feb 3, 2025
676f728
style: disable auth-locked sort options
ap-1 Feb 3, 2025
ccb585d
chore: update dependencies
ap-1 Feb 4, 2025
c95bc48
feat: give SortType values compatible with prisma
ap-1 Feb 4, 2025
2956f33
feat: pass fceAggregation info to backend
ap-1 Feb 4, 2025
5b33d47
feat: enable backend to use default aggregation data
ap-1 Feb 4, 2025
2a9e98d
refactor: separate seasons into their own parameters
ap-1 Feb 4, 2025
ea45ab9
feat: properly sort coursecards based on sort arguments
ap-1 Feb 4, 2025
e89c984
perf: disable sorting based on fceAggregation
ap-1 Feb 4, 2025
5d76298
fix: commit to trigger deployment
ap-1 Feb 4, 2025
00d2c95
feat: reintroduce season checking to sorting
ap-1 Feb 5, 2025
836f723
feat: add tooltip about sort order and fceAggregation
ap-1 Feb 5, 2025
a6f5b29
docs: add notice about clearing query
ap-1 Feb 5, 2025
7d46b7d
style: change reset button to match clear all button
ap-1 Feb 5, 2025
0b572e3
fix: ensure deleting pills on the search bar disables checkboxes
ap-1 Feb 5, 2025
f531369
refactor: simplify boolean expression
ap-1 Feb 5, 2025
5ddffb4
Merge remote-tracking branch 'origin/main' into sort
ap-1 Mar 19, 2025
e1df469
feat: allow for ranges of units when filtering by units: "var", "a-b"…
tsurbs May 27, 2025
7746e8a
Merge with main
tsurbs May 27, 2025
3c98bc4
fix: always sort by relevance (with lowest priority)
tsurbs May 27, 2025
b8e0c0c
chore: cleanup units range handling
tsurbs May 28, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# EditorConfig is awesome: https://EditorConfig.org

# top-most EditorConfig file
root = true

[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = false
insert_final_newline = false
239 changes: 223 additions & 16 deletions apps/backend/src/controllers/courses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import {
} from "~/util";
import { RequestHandler } from "express";
import db, { Prisma } from "@cmucourses/db";
import { SortOption, type Sort } from "~/../../apps/frontend/src/app/sorts";
import { initialState } from "~/../../apps/frontend/src/app/user";

const projection = { _id: false, __v: false };
const MAX_LIMIT = 10;
Expand Down Expand Up @@ -96,6 +98,11 @@ export interface GetFilteredCourses {
levels?: string;
session?: SingleOrArray<string>;
fces?: BoolLiteral;
sort?: SingleOrArray<string>;
numSemesters?: string;
spring?: BoolLiteral;
summer?: BoolLiteral;
fall?: BoolLiteral;
};
}

Expand All @@ -115,12 +122,10 @@ export const getFilteredCourses: RequestHandler<
const pipeline: Prisma.InputJsonValue[] = [];

const matchStage: Record<string, unknown> = {};
const sortKeys: [string, unknown][] = [];
const addedFields: Record<string, unknown> = {};

if (req.query.keywords !== undefined) {
matchStage.$text = { $search: req.query.keywords };
sortKeys.push(["score", { $meta: "textScore" }]);
addedFields.relevance = { $meta: "textScore" };
}

Expand All @@ -141,23 +146,51 @@ export const getFilteredCourses: RequestHandler<
if (unitsMin !== undefined || unitsMax !== undefined) {
pipeline.push({
$addFields: {
unitsDecimal: {
$convert: {
input: "$units",
to: "decimal",
onError: null,
onNull: null,
unitRangeMin: {
$cond: {
if: { $or: [{ $eq: ["$units", ""] }, { $eq: ["$units", "VAR"] }] },
then: 0,
else: {
$toDouble: {
$trim: {
input: {
// Some courses have units listed as 0-48 for example
// We don't want to complicate the pipeline, so just take the lower bound
$arrayElemAt: [{ $split: [{ $arrayElemAt: [{ $split: ["$units", "-"] }, 0] }, ","] }, 0],
},
},
},
},
},
},
unitRangeMax: {
$cond: {
if: { $or: [{ $eq: ["$units", ""] }, { $eq: ["$units", "VAR"] }] },
then: 50, // Default max units if not specified
else: {
$toDouble: {
$trim: {
input: {
// Some courses have units listed as 0-48 for example
// We don't want to complicate the pipeline, so just take the lower bound
$arrayElemAt: [{ $split: [{ $arrayElemAt: [{ $split: ["$units", "-"] }, -1] }, ","] }, -1],
},
},
},
},
},
},
},
});

pipeline.push({
$match: {
unitsDecimal: {
unitRangeMax: {
$gte: unitsMin,
$lte: unitsMax,
},
unitRangeMin: {
$lte: unitsMax,
}
},
});
}
Expand Down Expand Up @@ -195,16 +228,190 @@ export const getFilteredCourses: RequestHandler<

pipeline.push({ $addFields: addedFields as Prisma.InputJsonValue });
pipeline.push({ $project: projection as Prisma.InputJsonValue });

if (sortKeys.length > 0) {
const sortOptions: Record<string, unknown> = {};
for (const [key, option] of sortKeys) {
sortOptions[key] = option;
// Sort by best relevance, with lowest priority vs other sorts
pipeline.push({
$sort: {
relevance: -1,
}
});
if (req.query.sort !== undefined) {
// const numSemesters = parseOptionalInt(req.query.numSemesters, initialState.fceAggregation.numSemesters);
const spring = !req.query.spring ? initialState.fceAggregation.counted.spring : fromBoolLiteral(req.query.spring);
const summer = !req.query.summer ? initialState.fceAggregation.counted.summer : fromBoolLiteral(req.query.summer);
const fall = !req.query.fall ? initialState.fceAggregation.counted.fall : fromBoolLiteral(req.query.fall);

// Add a $lookup stage to join the fces collection
pipeline.push({
$lookup: {
from: "fces",
localField: "courseID",
foreignField: "courseID",
as: "fces",
},
});

// Unwind the fces array to de-normalize the data
pipeline.push({
$unwind: {
path: "$fces",
preserveNullAndEmptyArrays: true,
},
});

pipeline.push({ $sort: sortOptions as Prisma.InputJsonValue });
// Filter FCEs based on the counted settings
pipeline.push({
$match: {
$expr: {
$or: [
{ $and: [{ $eq: ["$fces.semester", "spring"] }, { $eq: [spring, true] }] },
{ $and: [{ $eq: ["$fces.semester", "summer"] }, { $eq: [summer, true] }] },
{ $and: [{ $eq: ["$fces.semester", "fall"] }, { $eq: [fall, true] }] },
],
},
},
});

// TODO: These take a long time to run and then return a 304 with no results
// So only the seasons from fceAggregation.counted are respected for now

// Sort FCEs by year and semester to find the latest ones
// pipeline.push({
// $addFields: {
// numericYear: { $toInt: "$fces.year" },
// numericSemester: {
// $switch: {
// branches: [
// { case: { $eq: ["$fces.semester", "fall"] }, then: 3 },
// { case: { $eq: ["$fces.semester", "summer"] }, then: 2 },
// { case: { $eq: ["$fces.semester", "spring"] }, then: 1 },
// ],
// default: 0,
// },
// },
// },
// });

// pipeline.push({
// $sort: {
// numericYear: SortType.Descending,
// numericSemester: SortType.Descending,
// },
// });

// Limit the number of FCEs to the specified numSemesters
// pipeline.push({
// $group: {
// _id: "$courseID",
// fces: { $push: "$fces" },
// },
// });

// pipeline.push({
// $project: {
// fces: { $slice: ["$fces", numSemesters] },
// },
// });

// Group by courseID to calculate aggregated values
pipeline.push({
$group: {
_id: "$courseID",
doc: { $first: "$$ROOT" },
avgTeachingRate: { $avg: { $arrayElemAt: ["$fces.rating", 7] } },
avgCourseRate: { $avg: { $arrayElemAt: ["$fces.rating", 8] } },
},
});

// Add the aggregated fields back to the root document
pipeline.push({
$replaceRoot: {
newRoot: {
$mergeObjects: ["$doc", { avgTeachingRate: "$avgTeachingRate", avgCourseRate: "$avgCourseRate" }],
},
},
});

const sorts = singleToArray(req.query.sort)
.reverse()
.map((sort) => JSON.parse(sort) as Sort);

sorts.forEach((sort) => {
switch (sort.option) {
case SortOption.FCE:
pipeline.push({
$addFields: {
fce: {
$avg: "$fces.hrsPerWeek",
},
},
});
pipeline.push({
$sort: {
fce: sort.type,
},
});
break;
case SortOption.TeachingRate:
pipeline.push({
$sort: {
avgTeachingRate: sort.type,
},
});
break;
case SortOption.CourseRate:
pipeline.push({
$sort: {
avgCourseRate: sort.type,
},
});
break;
case SortOption.Units:
pipeline.push({
$addFields: {
numericUnits: {
$cond: {
if: { $or: [{ $eq: ["$units", ""] }, { $eq: ["$units", "VAR"] }] },
then: 0,
else: {
$toDouble: {
$trim: {
input: {
// Some courses have units listed as 0-48 for example
// We don't want to complicate the pipeline, so just take the lower bound
$arrayElemAt: [{ $split: [{ $arrayElemAt: [{ $split: ["$units", "-"] }, 0] }, ","] }, 0],
},
},
},
},
},
},
},
});
pipeline.push({
$sort: {
numericUnits: sort.type,
},
});
break;
case SortOption.CourseNumber:
pipeline.push({
$addFields: {
numericCourseID: {
$toInt: { $arrayElemAt: [{ $split: ["$courseID", "-"] }, 1] },
},
},
});
pipeline.push({
$sort: {
numericCourseID: sort.type,
},
});
break;
}
});
}


const page = parseOptionalInt(req.query.page, 1);
const pageSize = Math.min(parseOptionalInt(req.query.pageSize, MAX_LIMIT), MAX_LIMIT);

Expand Down
3 changes: 3 additions & 0 deletions apps/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
},
"dependencies": {
"@clerk/nextjs": "^5.0.6",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@headlessui/react": "^1.6.4",
"@heroicons/react": "^2.0.8",
"@radix-ui/react-slider": "^1.0.0",
Expand All @@ -26,6 +28,7 @@
"@types/uuid": "^8.3.4",
"@yornaath/batshit": "^0.10.1",
"axios": "^0.24.0",
"clsx": "^2.1.1",
"downshift": "^6.1.7",
"fuse.js": "^7.0.0",
"jose": "^4.8.1",
Expand Down
25 changes: 21 additions & 4 deletions apps/frontend/src/app/api/course.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ import { useQuery, useQueries, keepPreviousData } from "@tanstack/react-query";
import { create, windowScheduler, keyResolver } from "@yornaath/batshit";
import { Course, Session } from "~/app/types";
import { STALE_TIME } from "~/app/constants";
import { FiltersState } from "~/app/filters";
import { type FiltersState } from "~/app/filters";
import { type SortState } from "~/app/sorts";
import { useAppSelector } from "~/app/hooks";
import { UserState } from "../user";

export type FetchCourseInfosByPageResult = {
docs: Course[];
Expand All @@ -20,7 +22,9 @@ export type FetchCourseInfosByPageResult = {
};

const fetchCourseInfosByPage = async (
filters: FiltersState
filters: FiltersState,
{ sorts }: SortState,
fceAggregation: UserState["fceAggregation"],
): Promise<FetchCourseInfosByPageResult> => {
const url = `${process.env.NEXT_PUBLIC_BACKEND_URL || ""}/courses/search?`;
const params = new URLSearchParams({
Expand Down Expand Up @@ -56,6 +60,17 @@ const fetchCourseInfosByPage = async (
if (value) params.append("levels", value);
}

if (sorts.length > 0) {
sorts.forEach((sort) => {
params.append("sort", JSON.stringify(sort));
});
}

params.append("numSemesters", fceAggregation.numSemesters.toString());
params.append("spring", fceAggregation.counted.spring.toString());
params.append("summer", fceAggregation.counted.summer.toString());
params.append("fall", fceAggregation.counted.fall.toString());

const response = await axios.get(url, {
headers: {
"Content-Type": "application/json",
Expand All @@ -68,10 +83,12 @@ const fetchCourseInfosByPage = async (

export const useFetchCourseInfosByPage = () => {
const filters = useAppSelector((state) => state.filters);
const sorts = useAppSelector((state) => state.sorts);
const fceAggregation = useAppSelector((state) => state.user.fceAggregation);

return useQuery({
queryKey: ["courseInfosByPage", filters],
queryFn: () => fetchCourseInfosByPage(filters),
queryKey: ["courseInfosByPage", filters, sorts, fceAggregation],
queryFn: () => fetchCourseInfosByPage(filters, sorts, fceAggregation),
staleTime: STALE_TIME,
placeholderData: keepPreviousData,
});
Expand Down
10 changes: 10 additions & 0 deletions apps/frontend/src/app/filters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,17 @@ export const filtersSlice = createSlice({
state.levels.selected[index] = false;
}
},
resetDepartments: (state) => {
state.departments = initialState.departments;
},
resetLevels: (state) => {
state.levels.selected = initialState.levels.selected;
},
resetSemesters: (state) => {
state.semesters.sessions = initialState.semesters.sessions;
},
resetFilters: (state) => {
// this will also reset the query through departments
state.departments = initialState.departments;
state.levels = initialState.levels;
state.units = initialState.units;
Expand Down
Loading
Loading