Skip to content
Open
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
7 changes: 1 addition & 6 deletions .vscode/mcp.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,3 @@
{
"servers": {
"nx-mcp": {
"type": "sse",
"url": "http://localhost:9980/sse"
}
}
"servers": {}
}
6 changes: 5 additions & 1 deletion apps/backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import morgan from "morgan";
import express, { ErrorRequestHandler } from "express";
import cors from "cors";
import { isUser } from "~/controllers/user";
import { getAllCourses, getCourseByID, getCourses, getFilteredCourses, getRequisites } from "~/controllers/courses";
import { getAllCourses, getCourseByID, getCourses, getFilteredCourses, getRequisites, getRequisitesGraph } from "~/controllers/courses";
import { getFCEs } from "~/controllers/fces";
import { getInstructors } from "~/controllers/instructors";
import { getGeneds } from "~/controllers/geneds";
Expand All @@ -24,6 +24,10 @@ app.route("/courses/all").get(getAllCourses);
app.route("/courses/requisites/:courseID").get(getRequisites);
app.route("/courses/search/").get(getFilteredCourses);
app.route("/courses/search/").post(isUser, getFilteredCourses);
// app.route("/courses/requisites-graph").get(getRequisitesGraph);
console.log("Registering /courses/requisites-graph");
app.get("/courses/requisites-graph", getRequisitesGraph);


app.route("/fces").post(isUser, getFCEs);

Expand Down
51 changes: 51 additions & 0 deletions apps/backend/src/controllers/courses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -321,3 +321,54 @@ export const getRequisites: RequestHandler = async (req, res, next) => {
next(e);
}
};

// --- Full Requisites Graph Endpoint (DAG) ---
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const getRequisitesGraph: RequestHandler = async (_req, res, next) => {
try {
const courses = await db.courses.findMany({
select: {
courseID: true,
name: true,
department: true,
units: true,
prereqs: true,
},
});

const nodes: Record<
string,
{
courseID: string;
name: string;
department: string;
units: string | number | null;
}
> = {};

const edges: { source: string; target: string; kind: "prereq" }[] = [];

for (const c of courses) {
// node for each course
nodes[c.courseID] = {
courseID: c.courseID,
name: c.name,
department: c.department,
units: c.units,
};

// edges for each prereq
for (const prereq of c.prereqs ?? []) {
edges.push({
source: prereq,
target: c.courseID,
kind: "prereq",
});
}
}

res.json({ nodes, edges });
} catch (err) {
next(err);
}
};
2 changes: 2 additions & 0 deletions apps/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"@types/uuid": "^10.0.0",
"@yornaath/batshit": "^0.10.1",
"axios": "^1.11.0",
"dagre": "^0.8.5",
"downshift": "^9.0.10",
"fuse.js": "^7.1.0",
"ical.js": "^2.2.1",
Expand All @@ -50,6 +51,7 @@
"react-spinners": "^0.17.0",
"react-string-replace": "^1.1.1",
"react-tooltip": "^5.29.1",
"reactflow": "^11.11.4",
"redux-persist": "^6.0.0",
"use-deep-compare-effect": "^1.8.1",
"uuid": "^11.1.0"
Expand Down
2 changes: 1 addition & 1 deletion apps/frontend/src/app/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ const reducers = combineReducers({
version: 1,
storage,
stateReconciler: autoMergeLevel2,
blacklist: ["session"],
blacklist: ["session", "searchBarMounted"],
},
uiReducer
),
Expand Down
6 changes: 6 additions & 0 deletions apps/frontend/src/app/ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,15 @@ export interface UIState {
darkMode: boolean;
sidebarOpen: boolean;
schedulesTopbarOpen: boolean;
// Guards against rendering multiple course search bars simultaneously.
searchBarMounted: boolean;
}

const initialState: UIState = {
darkMode: false,
sidebarOpen: true,
schedulesTopbarOpen: false,
searchBarMounted: false,
};

export const uiSlice = createSlice({
Expand All @@ -25,6 +28,9 @@ export const uiSlice = createSlice({
toggleSchedulesTopbarOpen: (state) => {
state.schedulesTopbarOpen = !state.schedulesTopbarOpen;
},
setSearchBarMounted: (state, action) => {
state.searchBarMounted = action.payload as boolean;
},
},
});

Expand Down
19 changes: 19 additions & 0 deletions apps/frontend/src/components/SearchBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { filtersSlice } from "~/app/filters";
import { getPillboxes } from "./filters/LevelFilter";
import { useFetchCourseInfosByPage } from "~/app/api/course";
import { useAuth } from "@clerk/nextjs";
import { uiSlice } from "~/app/ui";

const AppliedFiltersPill = ({
className,
Expand Down Expand Up @@ -129,6 +130,24 @@ const AppliedFilters = () => {

const SearchBar = () => {
const dispatch = useAppDispatch();
const searchBarMounted = useAppSelector((state) => state.ui.searchBarMounted);
const [hasClaimedSlot, setHasClaimedSlot] = useState(false);

useEffect(() => {
// Ensure only one search bar can render at a time by claiming a global slot.
if (!hasClaimedSlot && !searchBarMounted) {
setHasClaimedSlot(true);
dispatch(uiSlice.actions.setSearchBarMounted(true));
}

return () => {
if (hasClaimedSlot) {
dispatch(uiSlice.actions.setSearchBarMounted(false));
}
};
}, [dispatch, hasClaimedSlot, searchBarMounted]);

if (!hasClaimedSlot && searchBarMounted) return null;
const initialSearch = useAppSelector((state) => state.filters.search);
const [search, setSearch] = useState(initialSearch);

Expand Down
38 changes: 38 additions & 0 deletions apps/frontend/src/layoutGraph.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import * as dagre from "dagre";
import type { Node, Edge } from "reactflow";

const NODE_WIDTH = 180;
const NODE_HEIGHT = 60;

export function layoutDAG(
nodes: Node[],
edges: Edge[],
direction: "LR" | "TB" = "LR"
) {
const g = new dagre.graphlib.Graph({ directed: true });

g.setDefaultEdgeLabel(() => ({}));
g.setGraph({ rankdir: direction });

nodes.forEach((n) => {
g.setNode(n.id, { width: NODE_WIDTH, height: NODE_HEIGHT });
});

edges.forEach((e) => g.setEdge(e.source, e.target));

dagre.layout(g);

return {
nodes: nodes.map((n) => {
const pos = g.node(n.id);
return {
...n,
position: {
x: pos.x - NODE_WIDTH / 2,
y: pos.y - NODE_HEIGHT / 2,
},
};
}),
edges,
};
}
17 changes: 16 additions & 1 deletion apps/frontend/src/pages/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { NextPage } from "next";
import Link from "next/link";
import Filter from "~/components/Filter";
import Aggregate from "~/components/Aggregate";
import Topbar from "~/components/Topbar";
Expand All @@ -7,6 +8,7 @@ import CourseSearchList from "~/components/CourseSearchList";
import React from "react";
import { Page } from "~/components/Page";


const IndexPage: NextPage = () => {
return (
<Page
Expand All @@ -19,8 +21,20 @@ const IndexPage: NextPage = () => {
content={
<>
<Topbar>
<SearchBar />
<div className="flex w-full items-center gap-3">
<div className="flex-1">
<SearchBar />
</div>

<a
href="/requisites"
className="shrink-0 rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700"
>
Course Requisites Graph
</a>
</div>
</Topbar>

<CourseSearchList />
</>
}
Expand All @@ -29,4 +43,5 @@ const IndexPage: NextPage = () => {
);
};


export default IndexPage;
Loading
Loading