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
15 changes: 6 additions & 9 deletions apps/searchneu/app/api/scheduler/saved-plans/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,26 +55,23 @@ export async function POST(req: NextRequest) {
}

try {
const term = await getTerm(body.term);
if (!term) {
return Response.json({ error: "term not found" }, { status: 400 });
}

// Auto-generate name if not provided
let planName = body.name;
if (!planName) {
const existingPlans = await db
.select()
.from(savedPlansT)
.where(
and(
eq(savedPlansT.userId, user.id),
eq(savedPlansT.termId, parseInt(body.term, 10)),
),
and(eq(savedPlansT.userId, user.id), eq(savedPlansT.termId, term.id)),
);
planName = `Plan ${existingPlans.length + 1}`;
}

const term = await getTerm(body.term);
if (!term) {
return Response.json({ error: "term not found" }, { status: 400 });
}

// Create the saved plan
const [savedPlan] = await db
.insert(savedPlansT)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
import { SchedulerView } from "./calendar/SchedulerView";
import { ScheduleSidebar } from "./right-sidebar/ScheduleSidebar";
import { FilterPanel } from "./left-sidebar/FilterPanel";
import { GroupedTerms, Campus, Nupath } from "@/lib/catalog/types";
import { GroupedTerms, Campus, Nupath, Term } from "@/lib/catalog/types";
import {
PlanData,
PlanCourse,
Expand All @@ -33,7 +33,7 @@
campuses,
nupaths,
}: SchedulerWrapperProps) {
const router = useRouter();

Check warning on line 36 in apps/searchneu/components/scheduler/generator/SchedulerWrapper.tsx

View workflow job for this annotation

GitHub Actions / Lint

'router' is assigned a value but never used
const [selectedScheduleKey, setSelectedScheduleKey] = useState<string | null>(
null,
);
Expand All @@ -44,6 +44,7 @@
// Store the plan ID from when we save the plan initially
const [planId, setPlanId] = useState<number | null>(null);
const [planName, setPlanName] = useState<string>("Plan");
const [currentTerm, setCurrentTerm] = useState<Term>(terms.neu[0]);
const searchParams = useSearchParams();
const planIdFromUrl = searchParams.get("planId");

Expand Down Expand Up @@ -138,6 +139,16 @@
setPlanId(planIdNum);
setPlanName(planData.name);

// Find and set the current term by termId
if (planData.termId) {
// Search through the already-loaded terms prop to find matching term
const foundTerm = Object.values(terms).flat() as Term[];
const matchingTerm = foundTerm.find((t) => t.id === planData.termId);
if (matchingTerm) {
setCurrentTerm(matchingTerm);
}
}

// Extract locked courses and hidden sections from saved plan
const lockedCourseIds: Set<number> = new Set();
const hiddenSectionIds: Set<number> = new Set();
Expand Down Expand Up @@ -301,7 +312,7 @@
};

loadPlan();
}, [planIdFromUrl, planRefreshTrigger]);

Check warning on line 315 in apps/searchneu/components/scheduler/generator/SchedulerWrapper.tsx

View workflow job for this annotation

GitHub Actions / Lint

React Hook useEffect has missing dependencies: 'campusIdToName', 'filters', 'nupathIdToShort', and 'terms'. Either include them or remove the dependency array

// Update plan when filters change
useEffect(() => {
Expand Down Expand Up @@ -409,7 +420,7 @@
}, 300); // Debounce for 0.3 seconds

return () => clearTimeout(timer);
}, [filters, courseToSections]);

Check warning on line 423 in apps/searchneu/components/scheduler/generator/SchedulerWrapper.tsx

View workflow job for this annotation

GitHub Actions / Lint

React Hook useEffect has missing dependencies: 'campusIdToName', 'nupathShortToId', and 'planId'. Either include them or remove the dependency array

const regenerateSchedules = useCallback(
async (lockedCourseIds: Set<number>) => {
Expand Down Expand Up @@ -623,6 +634,7 @@
lockedCourseIds={filters.lockedCourseIds ?? new Set()}
onLockedCourseIdsChange={handleLockedCourseIdsChange}
planId={planIdFromUrl ? parseInt(planIdFromUrl) : undefined}
currentTerm={currentTerm}
onSchedulesGenerated={onSchedulesGenerated}
/>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
import { CoursesTab } from "./CoursesTab";
import { FiltersTab } from "./FiltersTab";
import AddCoursesModal from "../../shared/modal/AddCoursesModal";
import { GroupedTerms } from "@/lib/catalog/types";
import { GroupedTerms, Term } from "@/lib/catalog/types";

interface FilterPanelProps {
filters: ScheduleFilters;
Expand All @@ -23,6 +23,7 @@ interface FilterPanelProps {
lockedCourseIds: Set<number>;
onLockedCourseIdsChange: (ids: Set<number>) => void;
planId?: number;
currentTerm?: Term | null;
onSchedulesGenerated?: () => void;
}

Expand All @@ -39,6 +40,7 @@ export function FilterPanel({
lockedCourseIds,
onLockedCourseIdsChange,
planId,
currentTerm,
onSchedulesGenerated,
}: FilterPanelProps) {
const { openFeedback } = useFeedback();
Expand All @@ -52,7 +54,7 @@ export function FilterPanel({
open={isModalOpen}
closeFn={() => setIsModalOpen(false)}
terms={terms}
selectedTerm={null}
selectedTerm={currentTerm || null}
planId={planId}
callback={onSchedulesGenerated}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@
};

syncInitialCourses();
}, [activeTerm, initialExistingPlan]);

Check warning on line 223 in apps/searchneu/components/scheduler/shared/modal/AddCoursesModal.tsx

View workflow job for this annotation

GitHub Actions / Lint

React Hook useEffect has a missing dependency: 'fetchCoreqs'. Either include it or remove the dependency array

const handleSelectCourse = async (course: CourseSearchResult) => {
if (
Expand Down Expand Up @@ -339,10 +339,7 @@
<Button
className="cursor-pointer"
disabled={
isGenerating ||
selectedCourseGroups.length +
selectedCourseGroups.flatMap((g) => g.coreqs).length <
numCourses
isGenerating || selectedCourseGroups.length < numCourses
}
onClick={() => {
const allCourseIds = selectedCourseGroups.flatMap((g) => [
Expand Down
184 changes: 184 additions & 0 deletions apps/searchneu/lib/scheduler/coreqResolver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import { db, coursesT, subjectsT } from "@/lib/db";
import { eq, inArray, and } from "drizzle-orm";
import type { Requisite, RequisiteItem, Condition } from "@sneu/scraper/types";

/**
* Type guards for Requisite items
*/
const isCondition = (item: RequisiteItem): item is Condition => {
return "type" in item && "items" in item;
};

const isCourse = (
item: RequisiteItem,
): item is { subject: string; courseNumber: string } => {
return "subject" in item && "courseNumber" in item;
};

/**
* Recursively extract all course references from a Requisite tree
*/
function extractCoursesFromRequisite(
requisite: Requisite,
): Array<{ subject: string; courseNumber: string }> {
const courses: Array<{ subject: string; courseNumber: string }> = [];

if (Object.keys(requisite).length === 0) {
return courses; // Empty requisite
}

const walk = (item: RequisiteItem) => {
if (isCondition(item)) {
item.items.forEach(walk);
} else if (isCourse(item)) {
courses.push({ subject: item.subject, courseNumber: item.courseNumber });
}
// Ignore Test items - they're not courses
};

// Cast to RequisiteItem since we've already checked it's not empty
const item = requisite as RequisiteItem;

if (isCondition(item)) {
walk(item);
} else if (isCourse(item)) {
courses.push(item);
}

return courses;
}

/**
* Resolve course references to their IDs in the database
* Returns a Map of { subject + courseNumber } -> course ID
*/
async function resolveCourseReferences(
courseRefs: Array<{ subject: string; courseNumber: string }>,
termId: number,
): Promise<Map<string, number>> {
if (courseRefs.length === 0) return new Map();

const coreqCourses = await db
.select({
id: coursesT.id,
subject: subjectsT.code,
courseNumber: coursesT.courseNumber,
})
.from(coursesT)
.innerJoin(subjectsT, eq(coursesT.subject, subjectsT.id))
.where(
and(
eq(coursesT.termId, termId),
inArray(
coursesT.courseNumber,
courseRefs.map((r) => r.courseNumber),
),
),
);

const courseMap = new Map<string, number>();
for (const course of coreqCourses) {
const key = `${course.subject}:${course.courseNumber}`;
courseMap.set(key, course.id);
}

// Map references to IDs
const result = new Map<string, number>();
for (const ref of courseRefs) {
const key = `${ref.subject}:${ref.courseNumber}`;
const id = courseMap.get(key);
if (id) {
result.set(key, id);
}
}

return result;
}

/**
* Get all corequisite course IDs for a given course
* Returns an array of coreq course IDs in the same term
*/
export async function getCoreqCourseIds(courseId: number): Promise<number[]> {
const course = await db
.select({
coreqs: coursesT.coreqs,
termId: coursesT.termId,
})
.from(coursesT)
.where(eq(coursesT.id, courseId))
.limit(1);

if (!course || course.length === 0) {
return [];
}

const { coreqs, termId } = course[0];
const courseRefs = extractCoursesFromRequisite(coreqs as Requisite);
const coreqMap = await resolveCourseReferences(courseRefs, termId);

return Array.from(coreqMap.values());
}

/**
* Build corequisite groups from a list of course IDs
* Only groups together coreqs that are BOTH in the input list.
*
* Constraint logic:
* - If only 1 out of 2 coreqs is passed in → that 1 can stand alone
* - If 2 out of 2 coreqs are passed in → both must be in the schedule
* - If all N coreqs are passed in → all N must be in the schedule
*
* @example
* If courseIds = [101, 102, 103] where 101 has coreqs [102], 102 has coreqs [101]
* Returns [[101, 102], [103]]
*
* If courseIds = [101, 104] where 101 has coreqs [102], 102 has coreqs [101]
* Returns [[101], [104]] (102 wasn't passed in, so 101 stands alone)
*/
export async function buildCoreqGroups(
courseIds: number[],
): Promise<number[][]> {
const coreqMap = new Map<number, Set<number>>();

// Get all coreqs for each course
const allCoreqsResults = await Promise.all(courseIds.map(getCoreqCourseIds));

// Filter to only include coreqs that are also in the input list
courseIds.forEach((courseId, idx) => {
const relevantCoreqs = allCoreqsResults[idx].filter((c) =>
courseIds.includes(c),
);
coreqMap.set(courseId, new Set(relevantCoreqs));
});

// Build groups: courses that are coreqs of each other (only considering passed-in courses)
const visited = new Set<number>();
const groups: number[][] = [];

for (const courseId of courseIds) {
if (visited.has(courseId)) continue;

const group = [courseId];
visited.add(courseId);

// Find all courses in the coreq network
const queue = [courseId];
while (queue.length > 0) {
const current = queue.shift()!;
const coreqs = coreqMap.get(current) || new Set();

for (const coreq of coreqs) {
if (!visited.has(coreq)) {
group.push(coreq);
visited.add(coreq);
queue.push(coreq);
}
}
}

groups.push(group);
}

return groups;
}
Loading
Loading