Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
60 changes: 47 additions & 13 deletions backend/src/routes/CoursesRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@ router.get('/', isAuthenticated, async (req: Request, res: Response) => {
page = 1,
} = req.query;

const requirements = req.query.requirements
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you shoudl be putting it in the req.query object and handling it from there

? (req.query.requirements as string).split(',').map((r) => r.trim())
: [];

const numericLimit = parseInt(limit as string);
const numericPage = parseInt(page as string);
const skip = (numericPage - 1) * numericLimit;
Expand Down Expand Up @@ -109,7 +113,13 @@ router.get('/', isAuthenticated, async (req: Request, res: Response) => {

// Step 1: Get exact prefix matches (starts with, case-insensitive)
// For code searches, also use normalized matching to handle abbreviations

const exactMatchQuery: any = {};

if (requirements.length > 0) {
exactMatchQuery.requirement_names = { $in: requirements };
}

const escapedTerm = searchTerm.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const normalizedCodeRegex = createNormalizedCodeRegex(searchTerm);

Expand Down Expand Up @@ -196,6 +206,10 @@ router.get('/', isAuthenticated, async (req: Request, res: Response) => {
};
}

if (requirements.length > 0) {
fuzzyQuery.requirement_names = { $in: requirements };
}

// Build fuzzy search criteria using Atlas Search
const searchCriteria: any[] = [];

Expand Down Expand Up @@ -264,24 +278,44 @@ router.get('/', isAuthenticated, async (req: Request, res: Response) => {
},
};

// Build filter array for compound query
const filters: any[] = [];

if (schoolList.length > 0) {
searchStage.$search.compound.filter = [
{
compound: {
should: schoolList.map((school) => ({
wildcard: {
query: `*${school}`,
path: 'code',
allowAnalyzedField: true,
},
})),
minimumShouldMatch: 1,
},
filters.push({
compound: {
should: schoolList.map((school) => ({
wildcard: {
query: `*${school}`,
path: 'code',
allowAnalyzedField: true,
},
})),
minimumShouldMatch: 1,
},
});
}

if (requirements.length > 0) {
filters.push({
compound: {
should: requirements.map((req) => ({
queryString: {
defaultPath: 'requirement_names',
query: `"${req}"`,
},
})),
minimumShouldMatch: 1,
},
];
});
}

if (filters.length > 0) {
searchStage.$search.compound.filter = filters;
}

// Get fuzzy matches using aggregation

const fuzzyPipeline: any[] = [
searchStage,
{
Expand Down
145 changes: 136 additions & 9 deletions frontend/src/app/campus/courses/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,34 @@ const schoolData = {
},
};

const requirementOptions = [
'PO Area 1 Requirement',
'PO Area 2 Requirement',
'PO Area 3 Requirement',
'PO Area 4 Requirement',
'PO Area 5 Requirement',
'PO Area 6 Requirement',
'PO Writing Intensive Req',
'PO Speaking Intensive',
'PO Analyzing Differences',
'PO Language Requirement',
'PO Phys Ed Requirement',
];

const requirementShortNames: Record<string, string> = {
'PO Area 1 Requirement': 'Area 1',
'PO Area 2 Requirement': 'Area 2',
'PO Area 3 Requirement': 'Area 3',
'PO Area 4 Requirement': 'Area 4',
'PO Area 5 Requirement': 'Area 5',
'PO Area 6 Requirement': 'Area 6',
'PO Writing Intensive Req': 'Writing Intensive',
'PO Speaking Intensive': 'Speaking Intensive',
'PO Analyzing Differences': 'Analyzing Differences',
'PO Language Requirement': 'Language Requirement',
'PO Phys Ed Requirement': 'PE Requirement',
};

interface PaginationInfo {
currentPage: number;
totalPages: number;
Expand All @@ -69,6 +97,7 @@ interface SearchParams {
limit: number;
search: string;
searchType: SearchType;
requirements?: string;
}

const CourseSearchComponent = () => {
Expand Down Expand Up @@ -100,6 +129,15 @@ const CourseSearchComponent = () => {
};
});

const [selectedRequirements, setSelectedRequirements] = useState<
Set<string>
>(() => {
const reqs = searchParams.get('requirements');
return new Set(reqs ? reqs.split(',').filter(Boolean) : []);
});

const [showRequirements, setShowRequirements] = useState(false);

const [results, setResults] = useState<Course[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
Expand Down Expand Up @@ -138,21 +176,33 @@ const CourseSearchComponent = () => {
.filter(([_, isSelected]) => isSelected)
.map(([school]) => school);

const activeReqs = Array.from(selectedRequirements);

if (activeReqs.length > 0) {
params.set('requirements', activeReqs.join(','));
} else {
params.delete('requirements');
}

const allSelected = activeSchools.length === 5;
if (!allSelected && activeSchools.length > 0) {
params.set('schools', activeSchools.join(','));
} else {
params.delete('schools');
}

const currentReqParam = searchParams.get('requirements') || '';
const newReqParam = activeReqs.join(',');

// Reset to page 1 when search changes
const currentSearchParam = searchParams.get('search') || '';
const currentSchoolsParam = searchParams.get('schools') || '';
const newSchoolsParam = allSelected ? '' : activeSchools.join(',');

if (
searchTerm !== currentSearchParam ||
newSchoolsParam !== currentSchoolsParam
newSchoolsParam !== currentSchoolsParam ||
newReqParam !== currentReqParam
) {
params.set('page', '1');
}
Expand All @@ -169,7 +219,13 @@ const CourseSearchComponent = () => {
clearTimeout(urlSyncTimeoutRef.current);
}
};
}, [searchTerm, selectedSchools, searchParams, router]);
}, [
searchTerm,
selectedSchools,
selectedRequirements,
searchParams,
router,
]);

const fetchInstructors = useCallback(async (ids: number[]) => {
try {
Expand Down Expand Up @@ -237,13 +293,16 @@ const CourseSearchComponent = () => {
.filter(([_, isSelected]) => isSelected)
.map(([school]) => school);

const activeReqs = Array.from(selectedRequirements);

// Build search parameters
const searchParams: SearchParams = {
schools: activeSchools.join(','),
page: page,
limit: itemLimit,
search: searchTerm.replace(/\\/g, '').trim(),
searchType: searchType,
requirements: activeReqs.join(','),
};

const response = await axios.get<CoursesResponse>(
Expand Down Expand Up @@ -298,7 +357,7 @@ const CourseSearchComponent = () => {
setLoading(false);
}
},
[fetchInstructors]
[fetchInstructors, selectedRequirements]
);

const debouncedSearch = useMemo(
Expand All @@ -323,6 +382,7 @@ const CourseSearchComponent = () => {
searchTerm,
searchType,
selectedSchools,
selectedRequirements,
currentPage,
limit,
debouncedSearch,
Expand Down Expand Up @@ -475,6 +535,71 @@ const CourseSearchComponent = () => {
)}
</div>
</div>
<div className="mb-4">
<div
className="flex items-center gap-2 cursor-pointer select-none"
onClick={() => setShowRequirements((prev) => !prev)}
>
<span className="text-gray-600 text-sm">
{showRequirements ? '▲' : '▼'}
</span>

<span className="text-sm font-medium text-gray-700">
Filter by Graduation Requirements
</span>
</div>

{showRequirements && (
<div className="mt-3">
<div className="flex flex-wrap gap-2">
{requirementOptions.map((req) => {
const isSelected =
selectedRequirements.has(req);
return (
<button
key={req}
type="button"
onClick={() =>
setSelectedRequirements(
(prev) => {
const next =
new Set(prev);
if (next.has(req)) {
next.delete(
req
);
} else {
next.add(req);
}
return next;
}
)
}
className={`px-3 py-1 rounded-full text-sm font-medium transition-colors ${
isSelected
? 'bg-blue-100 text-blue-800 border-2 border-blue-300 shadow-md'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300 border-2 border-transparent'
}`}
>
{requirementShortNames[req]}
</button>
);
})}
</div>

{selectedRequirements.size > 0 && (
<div className="mt-4 p-3 rounded-md bg-yellow-100 border-l-4 border-yellow-400 text-yellow-800">
<p className="text-sm">
Some courses may be missing
graduation requirement information.
Results may not be fully
representative of all classes.
</p>
</div>
)}
</div>
)}
</div>
</div>

{error && (
Expand Down Expand Up @@ -644,12 +769,14 @@ const CourseSearchComponent = () => {
)}
</>
) : (
!loading &&
searchTerm && (
<div className="text-center py-8 text-gray-500">
No courses found matching your search criteria.
</div>
)
<>
{!loading && searchTerm && (
<div className="text-center py-8 text-gray-500">
No courses found matching your search
criteria.
</div>
)}
</>
)}
</div>
</div>
Expand Down