From 15d681ae0ae685d7335ffe00cffab0f079a70a53 Mon Sep 17 00:00:00 2001 From: Martin Hema Date: Sat, 4 Apr 2026 17:55:54 -0400 Subject: [PATCH] decoupling auth from scheduler navigation making url params the single source of truth to support guest and auth flows --- .../app/api/scheduler/saved-plans/route.ts | 21 +- .../app/scheduler/generator/loading.tsx | 36 + .../app/scheduler/generator/page.tsx | 75 +-- apps/searchneu/app/scheduler/page.tsx | 21 +- .../components/navigation/Footer.tsx | 2 +- .../components/navigation/Header.tsx | 6 +- .../components/navigation/NavBar.tsx | 21 +- .../components/navigation/SchedulerButton.tsx | 49 -- .../components/navigation/UserMenu.tsx | 13 +- .../scheduler/dashboard/Dashboard.tsx | 89 ++- .../scheduler/dashboard/PlanCard/PlanCard.tsx | 14 +- .../scheduler/generator/SchedulerWrapper.tsx | 637 ++---------------- .../generator/left-sidebar/FilterPanel.tsx | 13 +- .../shared/modal/AddCoursesModal.tsx | 187 ++--- apps/searchneu/lib/scheduler/filterParams.ts | 97 ++- apps/searchneu/lib/scheduler/hooks/index.ts | 4 + .../lib/scheduler/hooks/usePlanPersistence.ts | 210 ++++++ .../scheduler/hooks/useSchedulerFavorites.ts | 142 ++++ .../scheduler/hooks/useSchedulerFilters.ts | 35 + .../scheduler/hooks/useSchedulerSchedules.ts | 187 +++++ 20 files changed, 940 insertions(+), 919 deletions(-) create mode 100644 apps/searchneu/app/scheduler/generator/loading.tsx delete mode 100644 apps/searchneu/components/navigation/SchedulerButton.tsx create mode 100644 apps/searchneu/lib/scheduler/hooks/index.ts create mode 100644 apps/searchneu/lib/scheduler/hooks/usePlanPersistence.ts create mode 100644 apps/searchneu/lib/scheduler/hooks/useSchedulerFavorites.ts create mode 100644 apps/searchneu/lib/scheduler/hooks/useSchedulerFilters.ts create mode 100644 apps/searchneu/lib/scheduler/hooks/useSchedulerSchedules.ts diff --git a/apps/searchneu/app/api/scheduler/saved-plans/route.ts b/apps/searchneu/app/api/scheduler/saved-plans/route.ts index e89fc15f..7010980f 100644 --- a/apps/searchneu/app/api/scheduler/saved-plans/route.ts +++ b/apps/searchneu/app/api/scheduler/saved-plans/route.ts @@ -1,13 +1,13 @@ import { verifyUser } from "@/lib/dal/audits"; +import { getTerm } from "@/lib/dal/terms"; import { db, - savedPlansT, savedPlanCoursesT, savedPlanSectionsT, + savedPlansT, } from "@/lib/db"; -import { eq, and } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import { NextRequest } from "next/server"; -import { getTerm } from "@/lib/dal/terms"; interface SavePlanSection { sectionId: number; @@ -55,6 +55,11 @@ 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) { @@ -62,19 +67,11 @@ export async function POST(req: NextRequest) { .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) diff --git a/apps/searchneu/app/scheduler/generator/loading.tsx b/apps/searchneu/app/scheduler/generator/loading.tsx new file mode 100644 index 00000000..7b94d3a8 --- /dev/null +++ b/apps/searchneu/app/scheduler/generator/loading.tsx @@ -0,0 +1,36 @@ +export default function Loading() { + return ( +
+ {/* Left sidebar skeleton */} +
+
+
+
+
+
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ ))} +
+
+ + {/* Calendar skeleton */} +
+
+
+
+
+ + {/* Right sidebar skeleton */} +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ ))} +
+
+
+ ); +} diff --git a/apps/searchneu/app/scheduler/generator/page.tsx b/apps/searchneu/app/scheduler/generator/page.tsx index 6818e255..47350e75 100644 --- a/apps/searchneu/app/scheduler/generator/page.tsx +++ b/apps/searchneu/app/scheduler/generator/page.tsx @@ -1,68 +1,22 @@ import { SchedulerWrapper } from "@/components/scheduler/generator/SchedulerWrapper"; -import { getTerms } from "@/lib/dal/terms"; +import { auth } from "@/lib/auth/auth"; import { getCampuses } from "@/lib/dal/campuses"; import { getNupaths } from "@/lib/dal/nupaths"; - -import { db, nupathsT, savedPlansT } from "@/lib/db"; -import { auth } from "@/lib/auth/auth"; +import { getTerms } from "@/lib/dal/terms"; +import { db, nupathsT } from "@/lib/db"; import { headers } from "next/headers"; -import { notFound, redirect } from "next/navigation"; -import { eq, and } from "drizzle-orm"; - -export default async function Page({ - searchParams, -}: { - searchParams: Promise<{ - planId: string; - }>; -}) { - const session = await auth.api.getSession({ - headers: await headers(), - }); - - if (!session) { - redirect("/"); - } - - const params = await searchParams; - const planId = params.planId ? parseInt(params.planId) : null; - - if (!planId || isNaN(planId)) { - return
Invalid or missing plan ID
; - } - - let plan; - - try { - plan = await db.query.savedPlansT.findFirst({ - where: and( - eq(savedPlansT.id, planId), - eq(savedPlansT.userId, session.user.id), - ), - }); - } catch (error) { - console.error("Error loading plan:", error); - return notFound(); - } - - if (!plan) { - return notFound(); - } - - // Fetch available NUPath options - const nupathOptions = await db - .selectDistinct({ short: nupathsT.short, name: nupathsT.name }) - .from(nupathsT) - .then((c) => c.map((e) => ({ label: e.name, value: e.short }))); - - // Fetch terms from the db - const terms = await getTerms(); - - // Fetch campuses for the mapping - const campuses = await getCampuses(); - // Fetch nupaths for the mapping - const nupaths = await getNupaths(); +export default async function Page() { + const [session, nupathOptions, terms, campuses, nupaths] = await Promise.all([ + auth.api.getSession({ headers: await headers() }), + db + .selectDistinct({ short: nupathsT.short, name: nupathsT.name }) + .from(nupathsT) + .then((c) => c.map((e) => ({ label: e.name, value: e.short }))), + getTerms(), + getCampuses(), + getNupaths(), + ]); return (
@@ -71,6 +25,7 @@ export default async function Page({ terms={terms} campuses={campuses} nupaths={nupaths} + isLoggedIn={!!session?.user?.id} />
); diff --git a/apps/searchneu/app/scheduler/page.tsx b/apps/searchneu/app/scheduler/page.tsx index f3fe08a5..c42cfd72 100644 --- a/apps/searchneu/app/scheduler/page.tsx +++ b/apps/searchneu/app/scheduler/page.tsx @@ -1,11 +1,10 @@ -import { getTerms } from "@/lib/dal/terms"; -import { getCampuses } from "@/lib/dal/campuses"; -import { getNupaths } from "@/lib/dal/nupaths"; import { DashboardClient } from "@/components/scheduler/dashboard/Dashboard"; import { auth } from "@/lib/auth/auth"; -import { headers } from "next/headers"; -import { redirect } from "next/navigation"; +import { getCampuses } from "@/lib/dal/campuses"; +import { getNupaths } from "@/lib/dal/nupaths"; +import { getTerms } from "@/lib/dal/terms"; import { unstable_cache } from "next/cache"; +import { headers } from "next/headers"; const cachedCampuses = unstable_cache(getCampuses, [], { revalidate: 3600, @@ -18,23 +17,19 @@ const cachedNupaths = unstable_cache(getNupaths, [], { }); export default async function Dashboard() { - const session = await auth.api.getSession({ - headers: await headers(), - }); - - if (!session) { - redirect("/"); - } - const terms = getTerms(); const campuses = cachedCampuses(); const nupaths = cachedNupaths(); + const session = await auth.api.getSession({ + headers: await headers(), + }); return ( ); } diff --git a/apps/searchneu/components/navigation/Footer.tsx b/apps/searchneu/components/navigation/Footer.tsx index 04c398f6..dd88543b 100644 --- a/apps/searchneu/components/navigation/Footer.tsx +++ b/apps/searchneu/components/navigation/Footer.tsx @@ -1,8 +1,8 @@ "use client"; import Link from "next/link"; -import { Logo } from "../icons/logo"; import { Footskie } from "../icons/Footskie"; +import { Logo } from "../icons/logo"; export function LinkColumn({ name, diff --git a/apps/searchneu/components/navigation/Header.tsx b/apps/searchneu/components/navigation/Header.tsx index 07c032b7..5caf62c3 100644 --- a/apps/searchneu/components/navigation/Header.tsx +++ b/apps/searchneu/components/navigation/Header.tsx @@ -1,12 +1,12 @@ -import Link from "next/link"; -import { Logo } from "../icons/logo"; -import { UserIcon } from "./UserMenu"; import { graduateFlag, roomsFlag } from "@/lib/flags"; +import Link from "next/link"; import { Suspense } from "react"; +import { Logo } from "../icons/logo"; import { NavBar } from "./NavBar"; import { MobileNav } from "./MobileNav"; import { auth } from "@/lib/auth/auth"; import { headers } from "next/headers"; +import { UserIcon } from "./UserMenu"; export async function Header() { const enableRoomsPage = roomsFlag(); diff --git a/apps/searchneu/components/navigation/NavBar.tsx b/apps/searchneu/components/navigation/NavBar.tsx index ee869d6b..00e42029 100644 --- a/apps/searchneu/components/navigation/NavBar.tsx +++ b/apps/searchneu/components/navigation/NavBar.tsx @@ -1,18 +1,18 @@ "use client"; +import { cn } from "@/lib/cn"; +import { FlagValues } from "flags/react"; import { + Bell, + BookMarked, + Calendar, CircleQuestionMark, DoorOpen, GraduationCapIcon, - Bell, - BookMarked, } from "lucide-react"; -import { type ReactNode, use } from "react"; import Link from "next/link"; import { usePathname } from "next/navigation"; -import { FlagValues } from "flags/react"; +import { type ReactNode, use } from "react"; import { SheetClose } from "../ui/sheet"; -import { SchedulerButton } from "./SchedulerButton"; -import { cn } from "@/lib/cn"; export function NavBar({ flags, @@ -70,7 +70,14 @@ export function NavBar({ - + + + Scheduler + { - // eslint-disable-next-line react-hooks/set-state-in-effect - setHasMounted(true); - }, []); - - if (!hasMounted || !session) { - return ( - <> - - {showLoginPrompt && ( - setShowLoginPrompt(false)} - redirectUrl="/scheduler" - /> - )} - - ); - } - - return ( - - - Scheduler - - ); -} diff --git a/apps/searchneu/components/navigation/UserMenu.tsx b/apps/searchneu/components/navigation/UserMenu.tsx index adac786d..af64e337 100644 --- a/apps/searchneu/components/navigation/UserMenu.tsx +++ b/apps/searchneu/components/navigation/UserMenu.tsx @@ -1,17 +1,17 @@ "use client"; +import { authClient } from "@/lib/auth/auth-client"; +import { useState } from "react"; +import { Iconskie } from "../icons/Iconskie"; import { SignIn } from "../SignIn"; +import { Avatar, AvatarFallback } from "../ui/avatar"; import { Button } from "../ui/button"; -import { useState } from "react"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "../ui/dropdown-menu"; -import { Avatar, AvatarFallback } from "../ui/avatar"; -import { Iconskie } from "../icons/Iconskie"; -import { authClient } from "@/lib/auth/auth-client"; export function UserIcon() { const [showSI, setShowSI] = useState(false); @@ -52,7 +52,10 @@ function UserMenu() { authClient.signOut()} + onClick={async () => { + await authClient.signOut(); + window.location.reload(); + }} variant="destructive" > Sign Out diff --git a/apps/searchneu/components/scheduler/dashboard/Dashboard.tsx b/apps/searchneu/components/scheduler/dashboard/Dashboard.tsx index 58aa99e8..95bb7c55 100644 --- a/apps/searchneu/components/scheduler/dashboard/Dashboard.tsx +++ b/apps/searchneu/components/scheduler/dashboard/Dashboard.tsx @@ -4,7 +4,8 @@ import { Button } from "@/components/ui/button"; import type { Campus, Nupath, Term } from "@/lib/catalog/types"; import { GroupedTerms } from "@/lib/catalog/types"; import { cn } from "@/lib/cn"; -import { use, useState } from "react"; +import { useRouter } from "next/navigation"; +import { use, useCallback, useState } from "react"; import useSWR from "swr"; import { Searchskie } from "../../icons/Searchskie"; import AddCoursesModal from "../shared/modal/AddCoursesModal"; @@ -16,7 +17,7 @@ import { TermsDropdown } from "./TermsDropdown"; export type SavedPlan = { id: number; userId: string; - term: string; + termId: number; name: string; startTime: number | null; endTime: number | null; @@ -24,6 +25,7 @@ export type SavedPlan = { includeHonorsSections: boolean; includeRemoteSections: boolean; hideFilledSections: boolean; + numCourses: number | null; campus: number | null; nupaths: number[]; createdAt: Date; @@ -63,11 +65,14 @@ export function DashboardClient({ termsPromise, campusesPromise, nupathsPromise, + isLoggedIn, }: { termsPromise: Promise; campusesPromise: Promise; nupathsPromise: Promise; + isLoggedIn: boolean; }) { + const router = useRouter(); const [isModalOpen, setIsModalOpen] = useState(false); const [selectedCollege, setSelectedCollege] = @@ -84,14 +89,25 @@ export function DashboardClient({ isLoading, mutate, } = useSWR( - // selectedTerm will always be defined - `/api/scheduler/saved-plans/term/${selectedTerm.term}${selectedTerm.part}`, + isLoggedIn + ? `/api/scheduler/saved-plans/term/${selectedTerm.term}${selectedTerm.part}` + : null, (u: string) => fetch(u).then((r) => r.json()), { fallbackData: [], suspense: true }, ); - // this case should never happen as data is always defined (despite the type) - if (!plans) throw Error("fallback data not correctly set"); + const existingPlans = plans ?? []; + + const handleDashboardGenerate = useCallback( + (courseIds: number[], numCourses: number) => { + const params = new URLSearchParams(); + params.set("courseIds", courseIds.join(",")); + params.set("numCourses", numCourses.toString()); + params.set("term", selectedTerm.term + selectedTerm.part); + router.push(`/scheduler/generator?${params.toString()}`); + }, + [router, selectedTerm], + ); const handleDeletePlan = async (planId: number) => { if (!confirm("Are you sure you want to delete this plan?")) { @@ -109,7 +125,7 @@ export function DashboardClient({ { revalidate: false, rollbackOnError: true, - optimisticData: plans.filter((p) => p.id !== planId), + optimisticData: existingPlans.filter((p) => p.id !== planId), }, ); }; @@ -120,6 +136,8 @@ export function DashboardClient({ open={isModalOpen} terms={terms} selectedTerm={selectedTerm} + initialCourseIds={[]} + onGenerate={handleDashboardGenerate} closeFn={() => { setIsModalOpen(false); }} @@ -154,34 +172,47 @@ export function DashboardClient({ className={cn( `bg-neu1 flex h-full min-h-0 w-full flex-col place-content-center space-y-4 overflow-y-scroll rounded-lg border border-t-0 px-4 py-4 md:border-t-1`, { - "place-content-start": plans?.length > 0, + "place-content-start": existingPlans?.length > 0, }, )} > - {isLoading && ( -
-

Loading plans...

-
- )} - {!isLoading && plans.length === 0 && ( + {!isLoggedIn ? (
-

No plans found

-

Generate a new schedule first

-
- )} - {!isLoading && plans.length > 0 && ( -
- {plans.map((plan) => ( - - ))} +

+ Your plan is temporary and won't be saved. Sign in to keep + and access it later. +

+ ) : ( + <> + {isLoading && ( +
+

Loading plans...

+
+ )} + {!isLoading && existingPlans.length === 0 && ( +
+ +

No plans found

+

Generate a new schedule first

+
+ )} + {!isLoading && existingPlans.length > 0 && ( +
+ {existingPlans.map((plan) => ( + + ))} +
+ )} + )}
diff --git a/apps/searchneu/components/scheduler/dashboard/PlanCard/PlanCard.tsx b/apps/searchneu/components/scheduler/dashboard/PlanCard/PlanCard.tsx index 92f745d7..f84137b4 100644 --- a/apps/searchneu/components/scheduler/dashboard/PlanCard/PlanCard.tsx +++ b/apps/searchneu/components/scheduler/dashboard/PlanCard/PlanCard.tsx @@ -9,6 +9,7 @@ import { type CourseColor, getCourseColorMap, } from "@/lib/scheduler/courseColors"; +import { buildGeneratorUrl } from "@/lib/scheduler/filterParams"; import { MiniCalendar } from "../../shared/MiniCalendar"; import type { SectionWithCourse } from "@/lib/scheduler/filters"; import { FilterTags } from "./FilterTags"; @@ -16,18 +17,23 @@ import type { Nupath, Campus } from "@/lib/catalog/types"; interface PlanCardProps { plan: SavedPlan; + term: string; onDelete: (planId: number) => void; campuses: Campus[]; nupaths: Nupath[]; } -export function PlanCard({ plan, onDelete, campuses, nupaths }: PlanCardProps) { +export function PlanCard({ + plan, + term, + onDelete, + campuses, + nupaths, +}: PlanCardProps) { const router = useRouter(); const handleEdit = () => { - const params = new URLSearchParams(); - params.set("planId", plan.id.toString()); - router.push(`/scheduler/generator?${params.toString()}`); + router.push(buildGeneratorUrl({ ...plan, term }, campuses, nupaths)); }; const handleDelete = () => { diff --git a/apps/searchneu/components/scheduler/generator/SchedulerWrapper.tsx b/apps/searchneu/components/scheduler/generator/SchedulerWrapper.tsx index 705e4c09..bd6c41c1 100644 --- a/apps/searchneu/components/scheduler/generator/SchedulerWrapper.tsx +++ b/apps/searchneu/components/scheduler/generator/SchedulerWrapper.tsx @@ -1,30 +1,23 @@ "use client"; -import { useState, useEffect, useCallback, useMemo, useRef } from "react"; -import { useSearchParams, useRouter } from "next/navigation"; +import { Campus, GroupedTerms, Nupath } from "@/lib/catalog/types"; import { - filterSchedules, - type ScheduleFilters, - type SectionWithCourse, -} from "@/lib/scheduler/filters"; -import { getCourseColorMap } from "@/lib/scheduler/courseColors"; -import { getScheduleKey } from "@/lib/scheduler/scheduleKey"; + usePlanPersistence, + useSchedulerFavorites, + useSchedulerFilters, + useSchedulerSchedules, +} from "@/lib/scheduler/hooks"; +import { useCallback, useRef, useState } from "react"; 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 { - PlanData, - PlanCourse, - PlanSection, - PlanUpdateData, -} from "@/lib/scheduler/types"; +import { ScheduleSidebar } from "./right-sidebar/ScheduleSidebar"; interface SchedulerWrapperProps { nupathOptions: { label: string; value: string }[]; terms: GroupedTerms; campuses: Campus[]; nupaths: Nupath[]; + isLoggedIn: boolean; } export function SchedulerWrapper({ @@ -32,433 +25,46 @@ export function SchedulerWrapper({ terms, campuses, nupaths, + isLoggedIn, }: SchedulerWrapperProps) { - const router = useRouter(); - const [selectedScheduleKey, setSelectedScheduleKey] = useState( - null, - ); - // Map of schedule key to favorited-schedule ID from the API + const { filters, setFilters, toggleHiddenSection } = useSchedulerFilters(); + + const { + courseIds, + schedules, + filteredSchedules, + courseToSections, + colorMap, + currentScheduleKey, + setSelectedScheduleKey, + generateForCourses, + regenerateSchedules, + } = useSchedulerSchedules({ filters }); + const [favoritedSchedules, setFavoritedSchedules] = useState< Map >(new Map()); - // Store the plan ID from when we save the plan initially - const [planId, setPlanId] = useState(null); - const [planName, setPlanName] = useState("Plan"); - const searchParams = useSearchParams(); - const planIdFromUrl = searchParams.get("planId"); - const [filters, setFilters] = useState({}); - const [planRefreshTrigger, setPlanRefreshTrigger] = useState(0); - const [schedules, setSchedules] = useState([]); - const [courseToSections, setCourseToSections] = useState< - Map - >(new Map()); + const { planId, planName } = usePlanPersistence({ + isLoggedIn, + filters, + courseIds, + courseToSections, + campuses, + nupaths, + onFavoritesLoaded: setFavoritedSchedules, + }); + + const { isFavorited, toggleFavorite } = useSchedulerFavorites({ + planId, + schedules, + filteredSchedules, + isLoggedIn, + favoritedSchedules, + setFavoritedSchedules, + }); - // Track whether we've already synced the plan to avoid duplicate syncs - const planSyncRef = useRef(false); - // Track if we just loaded filters from DB to skip initial patch - const justLoadedFiltersRef = useRef(false); - // Track previous locked/hidden state to avoid unnecessary course updates - const prevLockedHiddenRef = useRef<{ - locked: Set; - hidden: Set; - } | null>(null); - // Track previous locked course IDs to detect unlocks const prevLockedCourseIdsRef = useRef>(new Set()); - // Debounce timer for schedule regeneration on unlock - const regenerateDebounceRef = useRef(null); - // Debounce timer for favorite/unfavorite requests - const favoriteDebounceRef = useRef(null); - - // Build campus ID -> campus name mapping from the provided campuses data - const campusIdToName = useMemo(() => { - const mapping = new Map(); - for (const campus of campuses) { - mapping.set(campus.id, campus.name); - } - return mapping; - }, [campuses]); - - // Build nupath ID -> short code mapping from the provided nupaths data - const nupathIdToShort = useMemo(() => { - const mapping = new Map(); - for (const nupath of nupaths) { - mapping.set(nupath.id, nupath.short); - } - return mapping; - }, [nupaths]); - - // Build nupath short code -> ID reverse mapping for saving - const nupathShortToId = useMemo(() => { - const mapping = new Map(); - for (const nupath of nupaths) { - mapping.set(nupath.short, nupath.id); - } - return mapping; - }, [nupaths]); - - const toggleHiddenSection = useCallback((sectionId: number) => { - setFilters((prev) => { - const next = new Set(prev.hiddenSectionIds ?? []); - if (next.has(sectionId)) next.delete(sectionId); - else next.add(sectionId); - return { ...prev, hiddenSectionIds: next }; - }); - }, []); - - // Reset sync ref when URL planId changes or when plan is refreshed - useEffect(() => { - planSyncRef.current = false; - }, [planIdFromUrl, planRefreshTrigger]); - - // Load existing plan from URL if available - useEffect(() => { - const loadPlan = async () => { - if (!planIdFromUrl) { - return; - } - - // Prevent duplicate loads in React Strict Mode - if (planSyncRef.current) { - return; - } - - planSyncRef.current = true; - - try { - const planIdNum = parseInt(planIdFromUrl); - const response = await fetch(`/api/scheduler/saved-plans/${planIdNum}`); - - if (!response.ok) { - console.error("Failed to load plan:", response.status); - return; - } - - const planData = (await response.json()) as PlanData; - setPlanId(planIdNum); - setPlanName(planData.name); - - // Extract locked courses and hidden sections from saved plan - const lockedCourseIds: Set = new Set(); - const hiddenSectionIds: Set = new Set(); - const allCourseIds: number[] = []; - - if (planData.courses && Array.isArray(planData.courses)) { - planData.courses.forEach((course: PlanCourse) => { - allCourseIds.push(course.courseId); - if (course.isLocked) { - lockedCourseIds.add(course.courseId); - } - if (course.sections && Array.isArray(course.sections)) { - course.sections.forEach((section: PlanSection) => { - if (section.isHidden) { - hiddenSectionIds.add(section.sectionId); - } - }); - } - }); - } - - // Apply saved filter values to the filters state - const newFilters: ScheduleFilters = { - ...filters, - startTime: planData.startTime, - endTime: planData.endTime, - specificDaysFree: planData.freeDays?.map(Number), - includeHonors: planData.includeHonorsSections, - includesRemote: planData.includeRemoteSections, - minSeatsLeft: planData.hideFilledSections ? 1 : undefined, - numCourses: planData.numCourses, - lockedCourseIds: lockedCourseIds, - hiddenSectionIds: hiddenSectionIds, - }; - - // Only include desiredCampus if campus is not null/undefined - if (planData.campus) { - const campusName = campusIdToName.get(planData.campus); - if (campusName) { - newFilters.desiredCampus = campusName; - } - } - - // Convert nupath IDs to short codes - if (planData.nupaths && planData.nupaths.length > 0) { - newFilters.nupaths = planData.nupaths - .map((id: number) => nupathIdToShort.get(id)) - .filter( - (short: string | undefined): short is string => - short !== undefined, - ); - } - - setFilters(newFilters); - justLoadedFiltersRef.current = true; - - // Fetch sections and generate schedules for the plan - if (allCourseIds.length > 0) { - try { - const courseToSectionsMap = new Map(); - - // Fetch sections for all courses in the plan - await Promise.all( - allCourseIds.map(async (courseId) => { - try { - const sectionsResponse = await fetch( - `/api/scheduler/sections/${courseId}`, - ); - if (!sectionsResponse.ok) { - throw new Error( - `Failed to fetch sections: ${sectionsResponse.status}`, - ); - } - const sections = await sectionsResponse.json(); - courseToSectionsMap.set(courseId, sections); - } catch (error) { - console.error( - `Failed to fetch sections for course ${courseId}:`, - error, - ); - } - }), - ); - - // Generate schedules with locked and optional courses - const lockedCourseIdsArray = Array.from(lockedCourseIds); - const optionalCourseIdsArray = allCourseIds.filter( - (id) => !lockedCourseIdsArray.includes(id), - ); - - const params = new URLSearchParams(); - if (lockedCourseIdsArray.length > 0) { - params.append("lockedCourseIds", lockedCourseIdsArray.join(",")); - } - if (optionalCourseIdsArray.length > 0) { - params.append( - "optionalCourseIds", - optionalCourseIdsArray.join(","), - ); - } - if (planData.numCourses !== undefined) { - params.append("numCourses", planData.numCourses.toString()); - } - - const schedulesResponse = await fetch( - `/api/scheduler/generate-schedules?${params.toString()}`, - ); - - if (!schedulesResponse.ok) { - throw new Error( - `Failed to generate schedules: ${schedulesResponse.status}`, - ); - } - - const generatedSchedules = await schedulesResponse.json(); - - setSchedules(generatedSchedules); - setCourseToSections(courseToSectionsMap); - - // Load schedule keys - const generatedScheduleKeys = new Set( - generatedSchedules.map((schedule: SectionWithCourse[]) => - getScheduleKey(schedule), - ), - ); - - // Load favorited schedule keys - const favMap = new Map(); - for (const favSchedule of planData.favoritedSchedules || []) { - if (favSchedule.sections && Array.isArray(favSchedule.sections)) { - const favScheduleKey = favSchedule.sections - .map((section: { sectionId: number }) => section.sectionId) - .sort((a: number, b: number) => a - b) - .join("|"); - // If key doesn't exist in generated schedules, unfavorite it - if (!generatedScheduleKeys.has(favScheduleKey)) { - try { - await fetch( - `/api/scheduler/favorited-schedules/${favSchedule.id}`, - { method: "DELETE" }, - ); - } catch (error) { - console.error( - `Failed to unfavorite schedule ${favSchedule.id}:`, - error, - ); - } - } else { - favMap.set(favScheduleKey, favSchedule.id); - } - } - } - setFavoritedSchedules(favMap); - } catch (error) { - console.error("Error loading sections and schedules:", error); - } - } - } catch (error) { - console.error("Error loading plan:", error); - } - }; - - loadPlan(); - }, [planIdFromUrl, planRefreshTrigger]); - - // Update plan when filters change - useEffect(() => { - // Don't update if we don't have a plan ID yet - if (!planId) { - return; - } - - // Skip the patch if we just loaded filters from the DB - if (justLoadedFiltersRef.current) { - justLoadedFiltersRef.current = false; - return; - } - - // Debounce the update to avoid excessive requests - const timer = setTimeout(async () => { - try { - // Helper to check set equality - const setsEqual = (a: Set, b: Set) => - a.size === b.size && [...a].every((id) => b.has(id)); - - // Check if locked/hidden sections have changed - const currentLocked = filters.lockedCourseIds ?? new Set(); - const currentHidden = filters.hiddenSectionIds ?? new Set(); - const prevLocked = prevLockedHiddenRef.current?.locked ?? new Set(); - const prevHidden = prevLockedHiddenRef.current?.hidden ?? new Set(); - - const lockedChanged = !setsEqual(currentLocked, prevLocked); - const hiddenChanged = !setsEqual(currentHidden, prevHidden); - - const updateData: PlanUpdateData = { - startTime: filters.startTime ?? null, - endTime: filters.endTime ?? null, - freeDays: filters.specificDaysFree ?? [], - includeHonorsSections: filters.includeHonors ?? false, - includeRemoteSections: filters.includesRemote ?? true, - hideFilledSections: (filters.minSeatsLeft ?? 0) > 0, - nupaths: [], - numCourses: filters.numCourses, - }; - - // Convert campus name back to ID for storage - if (filters.desiredCampus) { - // Find the campus ID for this campus name - let campusId: number | null = null; - for (const [id, name] of campusIdToName.entries()) { - if (name === filters.desiredCampus) { - campusId = id; - break; - } - } - updateData.campus = campusId; - } else { - updateData.campus = null; - } - - // Convert nupath short codes back to IDs for storage - if (filters.nupaths && filters.nupaths.length > 0) { - updateData.nupaths = filters.nupaths - .map((short: string) => nupathShortToId.get(short)) - .filter((id): id is number => id !== undefined); - } else { - updateData.nupaths = []; - } - - // Only include courses if locked/hidden changed - if (lockedChanged || hiddenChanged) { - const courses = Array.from(courseToSections.entries()).map( - ([courseId, sections]) => ({ - courseId, - isLocked: filters.lockedCourseIds?.has(courseId) ?? false, - sections: sections.map((section) => ({ - sectionId: section.id, - isHidden: filters.hiddenSectionIds?.has(section.id) ?? false, - })), - }), - ); - updateData.courses = courses; - - // Update the ref - prevLockedHiddenRef.current = { - locked: new Set(currentLocked), - hidden: new Set(currentHidden), - }; - } - - const response = await fetch(`/api/scheduler/saved-plans/${planId}`, { - method: "PATCH", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(updateData), - }); - - if (!response.ok) { - console.error( - "Failed to update plan:", - response.status, - response.statusText, - ); - } - } catch (error) { - console.error("Error updating plan:", error); - } - }, 300); // Debounce for 0.3 seconds - - return () => clearTimeout(timer); - }, [filters, courseToSections]); - - const regenerateSchedules = useCallback( - async (lockedCourseIds: Set) => { - if (courseToSections.size === 0) return; - - try { - // Calculate optional course IDs from the available courses - const allCourseIds = Array.from(courseToSections.keys()); - const lockedCourseIdsArray = Array.from(lockedCourseIds); - const optionalCourseIdsArray = allCourseIds.filter( - (id) => !lockedCourseIdsArray.includes(id), - ); - - // Build query parameters - const params = new URLSearchParams(); - if (lockedCourseIdsArray.length > 0) { - params.append("lockedCourseIds", lockedCourseIdsArray.join(",")); - } - if (optionalCourseIdsArray.length > 0) { - params.append("optionalCourseIds", optionalCourseIdsArray.join(",")); - } - if (filters.numCourses !== undefined) { - params.append("numCourses", filters.numCourses.toString()); - } - - // Fetch new schedules - const response = await fetch( - `/api/scheduler/generate-schedules?${params.toString()}`, - ); - - if (!response.ok) { - console.error("Failed to regenerate schedules:", response.status); - return; - } - - const newSchedules = await response.json(); - setSchedules(newSchedules); - } catch (error) { - console.error("Error regenerating schedules:", error); - } - }, - [courseToSections, filters.numCourses], - ); - - const onSchedulesGenerated = useCallback(() => { - setPlanRefreshTrigger((prev) => prev + 1); - }, []); - - const filteredSchedules = filterSchedules(schedules, filters); const handleLockedCourseIdsChange = useCallback( (ids: Set) => { @@ -467,155 +73,30 @@ export function SchedulerWrapper({ lockedCourseIds: ids.size > 0 ? ids : undefined, })); - // Detect if any courses were unlocked const wasUnlocked = Array.from(prevLockedCourseIdsRef.current).some( (id) => !ids.has(id), ); - - // Update the previous locked course IDs for next time prevLockedCourseIdsRef.current = new Set(ids); - // If a course was unlocked, debounce schedule regeneration if (wasUnlocked) { - // Clear existing debounce timer if any - if (regenerateDebounceRef.current) { - clearTimeout(regenerateDebounceRef.current); - } - - // Set new debounce timer - regenerateDebounceRef.current = setTimeout(() => { - // Regenerate schedules with the new locked course IDs - regenerateSchedules(ids); - regenerateDebounceRef.current = null; - }, 500); + regenerateSchedules(ids); } }, - [regenerateSchedules], + [setFilters, regenerateSchedules], ); - // Compute color map from all schedules (stable across filter changes) - const colorMap = useMemo(() => getCourseColorMap(schedules), [schedules]); - - const currentScheduleKey = - selectedScheduleKey ?? - (filteredSchedules.length > 0 - ? getScheduleKey(filteredSchedules[0]) - : null); - - const handleToggleFavorite = (key: string) => { - const isFavorited = favoritedSchedules.has(key); - - // Clear any pending debounce timer - if (favoriteDebounceRef.current) { - clearTimeout(favoriteDebounceRef.current); - } - - // Show optimistic update immediately - if (!isFavorited) { - setFavoritedSchedules((prev) => { - const next = new Map(prev); - next.set(key, 0); // Temporary ID - return next; - }); - } - - // Debounce the actual request - favoriteDebounceRef.current = setTimeout(() => { - if (isFavorited) { - // Unfavorite logic - const favoritedId = favoritedSchedules.get(key); - if (favoritedId) { - // optimistic update — remove immediately - setFavoritedSchedules((prev) => { - const next = new Map(prev); - next.delete(key); - return next; - }); - - fetch(`/api/scheduler/favorited-schedules/${favoritedId}`, { - method: "DELETE", - }).catch((error) => { - console.error("Error unfavoriting schedule:", error); - // Revert on error - setFavoritedSchedules((current) => { - const updated = new Map(current); - updated.set(key, favoritedId); - return updated; - }); - }); - } - } else { - // Favorite logic - const schedule = - filteredSchedules.find((s) => getScheduleKey(s) === key) ?? - schedules.find((s) => getScheduleKey(s) === key); - - if (!schedule) { - console.error("Could not find schedule to favorite"); - setFavoritedSchedules((current) => { - const updated = new Map(current); - updated.delete(key); - return updated; - }); - return; - } - - const sectionIds = (schedule as SectionWithCourse[]).map( - (section: SectionWithCourse) => section.id, - ); - - fetch("/api/scheduler/favorited-schedules", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - planId, - name: "My Favorited Schedule", - sectionIds, - }), - }) - .then((response) => { - if (!response.ok) { - return response.json().then((error) => { - throw new Error(JSON.stringify(error)); - }); - } - return response.json(); - }) - .then((data) => { - setFavoritedSchedules((current) => { - const updated = new Map(current); - updated.set(key, data.id); - return updated; - }); - }) - .catch((error) => { - console.error("Error favoriting schedule:", error); - setFavoritedSchedules((current) => { - const updated = new Map(current); - updated.delete(key); - return updated; - }); - }); - } - }, 300); - }; + const handleGenerate = useCallback( + async (newCourseIds: number[], numCourses: number) => { + setFilters((prev) => ({ ...prev, numCourses })); + await generateForCourses(newCourseIds, numCourses); + }, + [setFilters, generateForCourses], + ); - const isFavorited = currentScheduleKey - ? favoritedSchedules.has(currentScheduleKey) + const currentFavorited = currentScheduleKey + ? isFavorited(currentScheduleKey) : false; - // Clean up debounce timers on unmount - useEffect(() => { - return () => { - if (favoriteDebounceRef.current) { - clearTimeout(favoriteDebounceRef.current); - } - if (regenerateDebounceRef.current) { - clearTimeout(regenerateDebounceRef.current); - } - }; - }, []); - return (
@@ -629,8 +110,8 @@ export function SchedulerWrapper({ terms={terms} lockedCourseIds={filters.lockedCourseIds ?? new Set()} onLockedCourseIdsChange={handleLockedCourseIdsChange} - planId={planIdFromUrl ? parseInt(planIdFromUrl) : undefined} - onSchedulesGenerated={onSchedulesGenerated} + initialCourseIds={courseIds} + onGenerate={handleGenerate} />
@@ -643,11 +124,9 @@ export function SchedulerWrapper({ allSchedules={schedules} selectedScheduleKey={currentScheduleKey} colorMap={colorMap} - isFavorited={isFavorited} + isFavorited={currentFavorited} onToggleFavorite={() => { - if (selectedScheduleKey) { - handleToggleFavorite(selectedScheduleKey); - } + if (currentScheduleKey) toggleFavorite(currentScheduleKey); }} />
@@ -658,7 +137,7 @@ export function SchedulerWrapper({ selectedScheduleKey={currentScheduleKey} colorMap={colorMap} onSelectSchedule={setSelectedScheduleKey} - onToggleFavorite={handleToggleFavorite} + onToggleFavorite={toggleFavorite} />
diff --git a/apps/searchneu/components/scheduler/generator/left-sidebar/FilterPanel.tsx b/apps/searchneu/components/scheduler/generator/left-sidebar/FilterPanel.tsx index 247c4148..7306be73 100644 --- a/apps/searchneu/components/scheduler/generator/left-sidebar/FilterPanel.tsx +++ b/apps/searchneu/components/scheduler/generator/left-sidebar/FilterPanel.tsx @@ -22,8 +22,8 @@ interface FilterPanelProps { terms: GroupedTerms; lockedCourseIds: Set; onLockedCourseIdsChange: (ids: Set) => void; - planId?: number; - onSchedulesGenerated?: () => void; + initialCourseIds: number[]; + onGenerate: (courseIds: number[], numCourses: number) => void; } type Tab = "courses" | "filters"; @@ -38,8 +38,8 @@ export function FilterPanel({ terms, lockedCourseIds, onLockedCourseIdsChange, - planId, - onSchedulesGenerated, + initialCourseIds, + onGenerate, }: FilterPanelProps) { const { openFeedback } = useFeedback(); const [activeTab, setActiveTab] = useState("courses"); @@ -53,8 +53,9 @@ export function FilterPanel({ closeFn={() => setIsModalOpen(false)} terms={terms} selectedTerm={null} - planId={planId} - callback={onSchedulesGenerated} + initialCourseIds={initialCourseIds} + initialNumCourses={filters.numCourses} + onGenerate={onGenerate} /> {/* Tabs */}
diff --git a/apps/searchneu/components/scheduler/shared/modal/AddCoursesModal.tsx b/apps/searchneu/components/scheduler/shared/modal/AddCoursesModal.tsx index 5e142928..adb615b2 100644 --- a/apps/searchneu/components/scheduler/shared/modal/AddCoursesModal.tsx +++ b/apps/searchneu/components/scheduler/shared/modal/AddCoursesModal.tsx @@ -2,15 +2,14 @@ import { Course, - GroupedTerms, CourseSearchResult, + GroupedTerms, Term, } from "@/lib/catalog/types"; import { extractCoreqReqs, fetchCoreqCourses, fetchCourseById, - fetchSectionsForCourses, getSelectionText, isAlreadySelected, isCourseMatch, @@ -18,12 +17,10 @@ import { } from "@/lib/scheduler/addCourseModal"; import { CourseReq, - ExistingPlanData, ModalCourse, SelectedCourseGroupData, } from "@/lib/scheduler/types"; import dynamic from "next/dynamic"; -import { useRouter } from "next/navigation"; import { useCallback, useEffect, useState } from "react"; import { Button } from "../../../ui/button"; import { @@ -45,49 +42,36 @@ interface AddCoursesModalProps { closeFn: () => void; terms: GroupedTerms; selectedTerm: Term | null; - planId?: number; - callback?: () => void; + initialCourseIds: number[]; + initialNumCourses?: number; + onGenerate: (courseIds: number[], numCourses: number) => void; } -export default function AddCoursesModal(props: AddCoursesModalProps) { - const router = useRouter(); - - const [numCourses, setNumCourses] = useState(4); +export default function AddCoursesModal({ + open, + closeFn, + terms, + selectedTerm, + initialCourseIds, + initialNumCourses, + onGenerate, +}: AddCoursesModalProps) { + const [numCourses, setNumCourses] = useState(initialNumCourses ?? 4); const [searchQuery, setSearchQuery] = useState(""); const [selectedCourseGroups, setSelectedCourseGroups] = useState< SelectedCourseGroupData[] >([]); const [isGenerating, setIsGenerating] = useState(false); - const [initialExistingPlan, setInitialExistingPlan] = - useState(null); - // Fetch existing plan data if planId is provided (for updating) - // Refetch whenever the modal is opened to ensure we have the latest plan data + // Sync selected number of courses if it was anything other than default useEffect(() => { - if (!props.planId || !props.open) return; - - const fetchExistingPlan = async () => { - try { - const res = await fetch(`/api/scheduler/saved-plans/${props.planId}`, { - cache: "no-store", - }); - if (res.ok) { - const planData = await res.json(); - setInitialExistingPlan(planData); - if (planData.numCourses !== undefined) { - setNumCourses(planData.numCourses); - } - } - } catch (error) { - console.error("Error fetching existing plan:", error); - } - }; - - fetchExistingPlan(); - }, [props.planId, props.open]); + if (open && initialNumCourses != null) { + setNumCourses(initialNumCourses); + } + }, [open, initialNumCourses]); - const activeTerm = props.selectedTerm ?? props.terms.neu[0]; - const activeTermLabel = Object.values(props.terms) + const activeTerm = selectedTerm ?? terms.neu[0]; + const activeTermLabel = Object.values(terms) .flat() .find((t) => t.term === activeTerm.term)?.name; @@ -96,116 +80,12 @@ export default function AddCoursesModal(props: AddCoursesModalProps) { fetchCoreqCourses(course, currentGroups, activeTerm), [activeTerm], ); - - const handleGenerateSchedules = useCallback( - async (courseIds: number[], numCoursesValue: number) => { - setIsGenerating(true); - try { - // Fetch latest plan data in case filters were updated while modal was open - let latestPlanData: ExistingPlanData | null = null; - if (props.planId) { - try { - const res = await fetch( - `/api/scheduler/saved-plans/${props.planId}`, - { cache: "no-store" }, - ); - if (res.ok) { - latestPlanData = await res.json(); - } - } catch (error) { - console.error("Error fetching latest plan data:", error); - } - } - - const sectionsByCourseId = await fetchSectionsForCourses(courseIds); - - // Build courses array, preserving lock/hidden status from the latest plan data - const existingPlanForUpdate = latestPlanData || initialExistingPlan; - const courses = courseIds.map((courseId) => { - const sections = sectionsByCourseId.get(courseId) ?? []; - const existingCourse = existingPlanForUpdate?.courses.find( - (c) => c.courseId === courseId, - ); - - if (existingCourse) { - return { - courseId, - isLocked: existingCourse.isLocked, - sections: sections.map((s) => { - const existingSection = existingCourse.sections.find( - (es) => es.sectionId === s.id, - ); - return { - sectionId: s.id, - isHidden: existingSection?.isHidden ?? false, - }; - }), - }; - } else { - return { - courseId, - sections: sections.map((s) => ({ sectionId: s.id })), - }; - } - }); - - if (props.planId && (latestPlanData || initialExistingPlan)) { - const response = await fetch( - `/api/scheduler/saved-plans/${props.planId}`, - { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ courses, numCourses: numCoursesValue }), - }, - ); - - if (!response.ok) { - throw new Error(`Failed to update plan: ${response.statusText}`); - } - - props.callback?.(); - props.closeFn(); - } else { - const response = await fetch("/api/scheduler/saved-plans", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - term: activeTerm.term + activeTerm.part, - courses, - numCourses: numCoursesValue, - }), - }); - - if (!response.ok) { - throw new Error(`Failed to create plan: ${response.statusText}`); - } - - const plan = await response.json(); - router.push(`/scheduler/generator?planId=${plan.id}`); - props.closeFn(); - } - } catch (error) { - console.error("Error saving plan:", error); - alert("Failed to save plan. Please try again."); - } finally { - setIsGenerating(false); - } - }, - [activeTerm, router, props, initialExistingPlan], - ); - - // Initialize modal with existing plan courses if updating - // Only use initialExistingPlan to avoid reinitializing course selection during handleGenerateSchedules useEffect(() => { - const courseIdsToLoad = initialExistingPlan - ? initialExistingPlan.courses.map((c) => c.courseId) - : []; - - if (!courseIdsToLoad.length) return; + if (!open || initialCourseIds.length === 0) return; const syncInitialCourses = async () => { const rawResults = await Promise.all( - courseIdsToLoad.map(fetchCourseById), + initialCourseIds.map(fetchCourseById), ); const fetchedCourses = rawResults.filter((r): r is Course => r !== null); @@ -220,7 +100,22 @@ export default function AddCoursesModal(props: AddCoursesModalProps) { }; syncInitialCourses(); - }, [activeTerm, initialExistingPlan]); + }, [open, initialCourseIds, activeTerm, fetchCoreqs]); + + const handleGenerateSchedules = useCallback( + async (courseIds: number[], numCoursesValue: number) => { + setIsGenerating(true); + try { + onGenerate(courseIds, numCoursesValue); + closeFn(); + } catch (error) { + console.error("Error generating schedules:", error); + } finally { + setIsGenerating(false); + } + }, + [onGenerate, closeFn], + ); const handleSelectCourse = async (course: CourseSearchResult) => { if ( @@ -267,7 +162,7 @@ export default function AddCoursesModal(props: AddCoursesModalProps) { }; return ( - !open && props.closeFn()}> + !o && closeFn()}> Add Courses @@ -275,7 +170,7 @@ export default function AddCoursesModal(props: AddCoursesModalProps) { Add up to 6 courses{" "} (including corequisites) for{" "} - {activeTermLabel.split(" ").slice(0, 2).join(" ")}. + {activeTermLabel?.split(" ").slice(0, 2).join(" ")}. diff --git a/apps/searchneu/lib/scheduler/filterParams.ts b/apps/searchneu/lib/scheduler/filterParams.ts index 258e530c..8b1f5a38 100644 --- a/apps/searchneu/lib/scheduler/filterParams.ts +++ b/apps/searchneu/lib/scheduler/filterParams.ts @@ -1,3 +1,4 @@ +import type { Campus, Nupath } from "@/lib/catalog/types"; import type { ScheduleFilters } from "@/lib/scheduler/filters"; type Params = { get(name: string): string | null }; @@ -44,10 +45,7 @@ export function parseFiltersFromParams(params: Params): ScheduleFilters { if (ids.length > 0) filters.lockedCourseIds = new Set(ids); } - const desiredCampus = params.get("campuses"); - if (desiredCampus) { - filters.desiredCampus = desiredCampus; - } + filters.desiredCampus = params.get("campuses") || "Boston"; const hiddenSectionIds = params.get("hiddenSectionIds"); if (hiddenSectionIds) { @@ -109,7 +107,8 @@ export function syncToUrl(filters: ScheduleFilters) { "lockedCourseIds", Array.from(filters.lockedCourseIds).join(","), ); - if (filters.desiredCampus) params.set("campuses", filters.desiredCampus); + if (filters.desiredCampus && filters.desiredCampus !== "Boston") + params.set("campuses", filters.desiredCampus); if (filters.minSeatsLeft != null && filters.minSeatsLeft > 0) params.set("minSeats", String(filters.minSeatsLeft)); if (filters.numCourses != null) @@ -121,3 +120,91 @@ export function syncToUrl(filters: ScheduleFilters) { : window.location.pathname; window.history.replaceState(null, "", url); } + +export function parseCourseIdsFromParams(params: Params): number[] { + const courseIds = params.get("courseIds"); + if (!courseIds) return []; + return courseIds + .split(",") + .map(Number) + .filter((n) => !isNaN(n)); +} + +export function syncCourseIdsToUrl(courseIds: number[]) { + const params = new URLSearchParams(window.location.search); + if (courseIds.length > 0) { + params.set("courseIds", courseIds.join(",")); + } else { + params.delete("courseIds"); + } + const search = params.toString(); + const url = search + ? `${window.location.pathname}?${search}` + : window.location.pathname; + window.history.replaceState(null, "", url); +} + +export function buildGeneratorUrl( + plan: { + id?: number; + term: string; + numCourses?: number | null; + startTime: number | null; + endTime: number | null; + freeDays: string[]; + includeHonorsSections: boolean; + includeRemoteSections: boolean; + hideFilledSections: boolean; + campus: number | null; + nupaths: number[]; + courses: Array<{ + courseId: number; + isLocked: boolean; + sections: Array<{ sectionId: number; isHidden: boolean }>; + }>; + }, + campuses: Campus[], + nupaths: Nupath[], +): string { + const params = new URLSearchParams(); + + if (plan.id != null) params.set("planId", String(plan.id)); + params.set("term", plan.term); + + const courseIds = plan.courses.map((c) => c.courseId); + if (courseIds.length > 0) params.set("courseIds", courseIds.join(",")); + if (plan.startTime != null) params.set("startTime", String(plan.startTime)); + if (plan.endTime != null) params.set("endTime", String(plan.endTime)); + if (plan.freeDays?.length) params.set("freeDays", plan.freeDays.join(",")); + if (!plan.includeHonorsSections) params.set("honors", "false"); + if (!plan.includeRemoteSections) params.set("remote", "false"); + if (plan.hideFilledSections) params.set("minSeats", "1"); + + if (plan.campus != null) { + const campus = campuses.find((c) => c.id === plan.campus); + if (campus && campus.name !== "Boston") params.set("campuses", campus.name); + } + + if (plan.nupaths?.length) { + const shorts = plan.nupaths + .map((id) => nupaths.find((n) => n.id === id)?.short) + .filter(Boolean); + if (shorts.length > 0) params.set("nupaths", shorts.join(",")); + } + + if (plan.numCourses != null) { + params.set("numCourses", String(plan.numCourses)); + } + + const lockedIds = plan.courses + .filter((c) => c.isLocked) + .map((c) => c.courseId); + if (lockedIds.length > 0) params.set("lockedCourseIds", lockedIds.join(",")); + + const hiddenIds = plan.courses.flatMap((c) => + c.sections.filter((s) => s.isHidden).map((s) => s.sectionId), + ); + if (hiddenIds.length > 0) params.set("hiddenSectionIds", hiddenIds.join(",")); + + return `/scheduler/generator?${params.toString()}`; +} diff --git a/apps/searchneu/lib/scheduler/hooks/index.ts b/apps/searchneu/lib/scheduler/hooks/index.ts new file mode 100644 index 00000000..34fa4627 --- /dev/null +++ b/apps/searchneu/lib/scheduler/hooks/index.ts @@ -0,0 +1,4 @@ +export { useSchedulerFilters } from "./useSchedulerFilters"; +export { useSchedulerSchedules } from "./useSchedulerSchedules"; +export { useSchedulerFavorites } from "./useSchedulerFavorites"; +export { usePlanPersistence } from "./usePlanPersistence"; diff --git a/apps/searchneu/lib/scheduler/hooks/usePlanPersistence.ts b/apps/searchneu/lib/scheduler/hooks/usePlanPersistence.ts new file mode 100644 index 00000000..7340980e --- /dev/null +++ b/apps/searchneu/lib/scheduler/hooks/usePlanPersistence.ts @@ -0,0 +1,210 @@ +"use client"; + +import type { Campus, Nupath } from "@/lib/catalog/types"; +import { useSearchParams } from "next/navigation"; +import { useEffect, useMemo, useRef, useState } from "react"; +import type { ScheduleFilters, SectionWithCourse } from "../filters"; +import type { PlanUpdateData } from "../types"; + +interface UsePlanPersistenceArgs { + isLoggedIn: boolean; + filters: ScheduleFilters; + courseIds: number[]; + courseToSections: Map; + campuses: Campus[]; + nupaths: Nupath[]; + onFavoritesLoaded: (favorites: Map) => void; +} + +export function usePlanPersistence({ + isLoggedIn, + filters, + courseIds, + courseToSections, + campuses, + nupaths, + onFavoritesLoaded, +}: UsePlanPersistenceArgs) { + const searchParams = useSearchParams(); + const planIdFromUrl = searchParams.get("planId"); + const termFromUrl = searchParams.get("term"); + + const [planId, setPlanId] = useState( + planIdFromUrl ? parseInt(planIdFromUrl) : null, + ); + const [planName, setPlanName] = useState("Plan"); + const resolvedRef = useRef(!!planIdFromUrl); + + const campusNameToId = useMemo(() => { + const m = new Map(); + for (const c of campuses) m.set(c.name, c.id); + return m; + }, [campuses]); + + const nupathShortToId = useMemo(() => { + const m = new Map(); + for (const n of nupaths) m.set(n.short, n.id); + return m; + }, [nupaths]); + + useEffect(() => { + if (!isLoggedIn || resolvedRef.current) return; + if (courseIds.length === 0 || courseToSections.size === 0 || !termFromUrl) + return; + resolvedRef.current = true; + + (async () => { + try { + // Fallback: find matching plan by courseIds, or create new + const res = await fetch( + `/api/scheduler/saved-plans/term/${termFromUrl}`, + ); + if (!res.ok) { + console.error("Failed to fetch plans:", res.status); + return; + } + + const plans = await res.json(); + const sortedTarget = [...courseIds].sort((a, b) => a - b).join(","); + const matched = plans.find( + (p: { courses: { courseId: number }[] }) => + p.courses + .map((c: { courseId: number }) => c.courseId) + .sort((a: number, b: number) => a - b) + .join(",") === sortedTarget, + ); + + if (matched) { + setPlanId(matched.id); + setPlanName(matched.name); + addPlanIdToUrl(matched.id); + + const favMap = new Map(); + for (const fav of matched.favoritedSchedules || []) { + if (fav.sections) { + const key = fav.sections + .map((s: { sectionId: number }) => s.sectionId) + .sort((a: number, b: number) => a - b) + .join("|"); + favMap.set(key, fav.id); + } + } + onFavoritesLoaded(favMap); + } else { + const courses = courseIds.map((courseId) => ({ + courseId, + sections: + courseToSections + .get(courseId) + ?.map((s) => ({ sectionId: s.id })) ?? [], + })); + + const createRes = await fetch("/api/scheduler/saved-plans", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + term: termFromUrl, + courses, + numCourses: filters.numCourses ?? 4, + }), + }); + + if (createRes.ok) { + const plan = await createRes.json(); + setPlanId(plan.id); + setPlanName(plan.name ?? "Plan"); + addPlanIdToUrl(plan.id); + } + } + } catch (error) { + console.error("Error resolving plan:", error); + } + })(); + }, [isLoggedIn, termFromUrl, courseIds, courseToSections]); + + // planId is present in URL + useEffect(() => { + if (!isLoggedIn || !planIdFromUrl || !termFromUrl) return; + + (async () => { + try { + const res = await fetch(`/api/scheduler/saved-plans/${planIdFromUrl}`); + if (!res.ok) return; + const plan = await res.json(); + if (plan.name) setPlanName(plan.name); + + const favMap = new Map(); + for (const fav of plan.favoritedSchedules || []) { + if (fav.sections) { + const key = fav.sections + .map((s: { sectionId: number }) => s.sectionId) + .sort((a: number, b: number) => a - b) + .join("|"); + favMap.set(key, fav.id); + } + } + onFavoritesLoaded(favMap); + } catch (error) { + console.error("Error loading plan:", error); + } + })(); + }, [planIdFromUrl]); + + // Persist filter changes to DB + useEffect(() => { + if (!planId || !isLoggedIn) return; + + const timer = setTimeout(async () => { + try { + const updateData: PlanUpdateData = { + startTime: filters.startTime ?? null, + endTime: filters.endTime ?? null, + freeDays: filters.specificDaysFree ?? [], + includeHonorsSections: filters.includeHonors ?? false, + includeRemoteSections: filters.includesRemote ?? true, + hideFilledSections: (filters.minSeatsLeft ?? 0) > 0, + nupaths: + filters.nupaths + ?.map((short) => nupathShortToId.get(short)) + .filter((id): id is number => id !== undefined) ?? [], + numCourses: filters.numCourses, + campus: filters.desiredCampus + ? (campusNameToId.get(filters.desiredCampus) ?? null) + : null, + courses: Array.from(courseToSections.entries()).map( + ([courseId, sections]) => ({ + courseId, + isLocked: filters.lockedCourseIds?.has(courseId) ?? false, + sections: sections.map((s) => ({ + sectionId: s.id, + isHidden: filters.hiddenSectionIds?.has(s.id) ?? false, + })), + }), + ), + }; + + await fetch(`/api/scheduler/saved-plans/${planId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(updateData), + }); + } catch (error) { + console.error("Error updating plan:", error); + } + }, 300); + + return () => clearTimeout(timer); + }, [filters, courseToSections, planId, isLoggedIn]); + + return { planId, planName }; +} + +function addPlanIdToUrl(id: number) { + const params = new URLSearchParams(window.location.search); + params.set("planId", String(id)); + window.history.replaceState( + null, + "", + `${window.location.pathname}?${params.toString()}`, + ); +} diff --git a/apps/searchneu/lib/scheduler/hooks/useSchedulerFavorites.ts b/apps/searchneu/lib/scheduler/hooks/useSchedulerFavorites.ts new file mode 100644 index 00000000..e2800b49 --- /dev/null +++ b/apps/searchneu/lib/scheduler/hooks/useSchedulerFavorites.ts @@ -0,0 +1,142 @@ +"use client"; + +import { + Dispatch, + SetStateAction, + useCallback, + useEffect, + useRef, +} from "react"; +import type { SectionWithCourse } from "../filters"; +import { getScheduleKey } from "../scheduleKey"; + +interface UseSchedulerFavoritesArgs { + planId: number | null; + schedules: SectionWithCourse[][]; + filteredSchedules: SectionWithCourse[][]; + isLoggedIn: boolean; + favoritedSchedules: Map; + setFavoritedSchedules: Dispatch>>; +} + +interface UseSchedulerFavoritesReturn { + isFavorited: (key: string) => boolean; + toggleFavorite: (key: string) => void; +} + +export function useSchedulerFavorites({ + planId, + schedules, + filteredSchedules, + isLoggedIn, + favoritedSchedules, + setFavoritedSchedules, +}: UseSchedulerFavoritesArgs): UseSchedulerFavoritesReturn { + const debounceRef = useRef(null); + + const isFavorited = useCallback( + (key: string) => favoritedSchedules.has(key), + [favoritedSchedules], + ); + + const toggleFavorite = useCallback( + (key: string) => { + const currentlyFavorited = favoritedSchedules.has(key); + + if (debounceRef.current) clearTimeout(debounceRef.current); + + // Optimistic update for favoriting + if (!currentlyFavorited) { + setFavoritedSchedules((prev) => { + const next = new Map(prev); + next.set(key, 0); + return next; + }); + } + + debounceRef.current = setTimeout(() => { + if (currentlyFavorited) { + const favoritedId = favoritedSchedules.get(key); + setFavoritedSchedules((prev) => { + const next = new Map(prev); + next.delete(key); + return next; + }); + + if (favoritedId && isLoggedIn) { + fetch(`/api/scheduler/favorited-schedules/${favoritedId}`, { + method: "DELETE", + }).catch(() => { + setFavoritedSchedules((prev) => { + const next = new Map(prev); + next.set(key, favoritedId); + return next; + }); + }); + } + } else { + const schedule = + filteredSchedules.find((s) => getScheduleKey(s) === key) ?? + schedules.find((s) => getScheduleKey(s) === key); + + if (!schedule) { + setFavoritedSchedules((prev) => { + const next = new Map(prev); + next.delete(key); + return next; + }); + return; + } + + if (!isLoggedIn || !planId) return; + + const sectionIds = schedule.map((s) => s.id); + + fetch("/api/scheduler/favorited-schedules", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + planId, + name: "My Favorited Schedule", + sectionIds, + }), + }) + .then((res) => { + if (!res.ok) throw new Error("Failed to favorite"); + return res.json(); + }) + .then((data) => { + setFavoritedSchedules((prev) => { + const next = new Map(prev); + next.set(key, data.id); + return next; + }); + }) + .catch(() => { + setFavoritedSchedules((prev) => { + const next = new Map(prev); + next.delete(key); + return next; + }); + }); + } + }, 300); + }, + [ + favoritedSchedules, + filteredSchedules, + schedules, + planId, + isLoggedIn, + setFavoritedSchedules, + ], + ); + + useEffect(() => { + return () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + }; + }, []); + + return { isFavorited, toggleFavorite }; +} diff --git a/apps/searchneu/lib/scheduler/hooks/useSchedulerFilters.ts b/apps/searchneu/lib/scheduler/hooks/useSchedulerFilters.ts new file mode 100644 index 00000000..ee5dfb65 --- /dev/null +++ b/apps/searchneu/lib/scheduler/hooks/useSchedulerFilters.ts @@ -0,0 +1,35 @@ +"use client"; + +import { useSearchParams } from "next/navigation"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { parseFiltersFromParams, syncToUrl } from "../filterParams"; +import type { ScheduleFilters } from "../filters"; + +export function useSchedulerFilters() { + const searchParams = useSearchParams(); + const mountedRef = useRef(false); + + const [filters, setFilters] = useState(() => + parseFiltersFromParams(searchParams), + ); + + // Sync filters to URL on every change (after initial mount) + useEffect(() => { + if (!mountedRef.current) { + mountedRef.current = true; + return; + } + syncToUrl(filters); + }, [filters]); + + const toggleHiddenSection = useCallback((sectionId: number) => { + setFilters((prev) => { + const next = new Set(prev.hiddenSectionIds ?? []); + if (next.has(sectionId)) next.delete(sectionId); + else next.add(sectionId); + return { ...prev, hiddenSectionIds: next }; + }); + }, []); + + return { filters, setFilters, toggleHiddenSection }; +} diff --git a/apps/searchneu/lib/scheduler/hooks/useSchedulerSchedules.ts b/apps/searchneu/lib/scheduler/hooks/useSchedulerSchedules.ts new file mode 100644 index 00000000..7176e360 --- /dev/null +++ b/apps/searchneu/lib/scheduler/hooks/useSchedulerSchedules.ts @@ -0,0 +1,187 @@ +"use client"; + +import { useSearchParams } from "next/navigation"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { getCourseColorMap } from "../courseColors"; +import { parseCourseIdsFromParams, syncCourseIdsToUrl } from "../filterParams"; +import { + filterSchedules, + type ScheduleFilters, + type SectionWithCourse, +} from "../filters"; +import { getScheduleKey } from "../scheduleKey"; + +interface UseSchedulerSchedulesArgs { + filters: ScheduleFilters; +} + +export function useSchedulerSchedules({ filters }: UseSchedulerSchedulesArgs) { + const searchParams = useSearchParams(); + + const [courseIds, setCourseIdsState] = useState(() => + parseCourseIdsFromParams(searchParams), + ); + const [schedules, setSchedules] = useState([]); + const [courseToSections, setCourseToSections] = useState< + Map + >(new Map()); + const [selectedScheduleKey, setSelectedScheduleKey] = useState( + null, + ); + const [isLoading, setIsLoading] = useState(false); + + const initialLoadRef = useRef(false); + const regenerateDebounceRef = useRef(null); + + const setCourseIds = useCallback((ids: number[]) => { + setCourseIdsState(ids); + syncCourseIdsToUrl(ids); + }, []); + + const filteredSchedules = useMemo( + () => filterSchedules(schedules, filters), + [schedules, filters], + ); + + const colorMap = useMemo(() => getCourseColorMap(schedules), [schedules]); + + const currentScheduleKey = + selectedScheduleKey ?? + (filteredSchedules.length > 0 + ? getScheduleKey(filteredSchedules[0]) + : null); + + const fetchSections = async ( + ids: number[], + ): Promise> => { + const map = new Map(); + await Promise.all( + ids.map(async (courseId) => { + try { + const res = await fetch(`/api/scheduler/sections/${courseId}`); + if (res.ok) map.set(courseId, await res.json()); + } catch (error) { + console.error( + `Failed to fetch sections for course ${courseId}:`, + error, + ); + } + }), + ); + return map; + }; + + const callGenerateApi = async ( + ids: number[], + numCourses: number, + lockedCourseIds?: Set, + ): Promise => { + const locked = lockedCourseIds ? Array.from(lockedCourseIds) : []; + const optional = ids.filter((id) => !locked.includes(id)); + + const params = new URLSearchParams(); + if (locked.length > 0) params.append("lockedCourseIds", locked.join(",")); + if (optional.length > 0) + params.append("optionalCourseIds", optional.join(",")); + params.append("numCourses", numCourses.toString()); + + const res = await fetch( + `/api/scheduler/generate-schedules?${params.toString()}`, + ); + if (!res.ok) throw new Error(`Failed to generate schedules: ${res.status}`); + return await res.json(); + }; + + useEffect(() => { + if (courseIds.length === 0 || initialLoadRef.current) return; + initialLoadRef.current = true; + + const autoLoad = async () => { + setIsLoading(true); + try { + const sections = await fetchSections(courseIds); + setCourseToSections(sections); + + const newSchedules = await callGenerateApi( + courseIds, + filters.numCourses ?? 4, + filters.lockedCourseIds, + ); + setSchedules(newSchedules); + } catch (error) { + console.error("Error auto-loading schedules:", error); + } finally { + setIsLoading(false); + } + }; + + autoLoad(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const generateForCourses = useCallback( + async (ids: number[], numCourses: number) => { + setCourseIds(ids); + setIsLoading(true); + try { + const sections = await fetchSections(ids); + setCourseToSections(sections); + + const newSchedules = await callGenerateApi(ids, numCourses); + setSchedules(newSchedules); + } catch (error) { + console.error("Error generating schedules:", error); + } finally { + setIsLoading(false); + } + }, + [setCourseIds], + ); + + const regenerateSchedules = useCallback( + async (lockedCourseIds: Set) => { + if (courseToSections.size === 0) return; + + if (regenerateDebounceRef.current) { + clearTimeout(regenerateDebounceRef.current); + } + + regenerateDebounceRef.current = setTimeout(async () => { + try { + const allIds = Array.from(courseToSections.keys()); + const newSchedules = await callGenerateApi( + allIds, + filters.numCourses ?? 4, + lockedCourseIds, + ); + setSchedules(newSchedules); + } catch (error) { + console.error("Error regenerating schedules:", error); + } + regenerateDebounceRef.current = null; + }, 500); + }, + [courseToSections, filters.numCourses], + ); + + useEffect(() => { + return () => { + if (regenerateDebounceRef.current) + clearTimeout(regenerateDebounceRef.current); + }; + }, []); + + return { + courseIds, + setCourseIds, + schedules, + filteredSchedules, + courseToSections, + colorMap, + currentScheduleKey, + setSelectedScheduleKey, + generateForCourses, + regenerateSchedules, + isLoading, + }; +}