From 88620b0d18febf7ba9579009d1863b523210b82d Mon Sep 17 00:00:00 2001 From: Kang Jiaming Date: Mon, 10 Nov 2025 02:35:10 -0500 Subject: [PATCH 01/12] feat: add plan table course search and add --- apps/web/package.json | 1 + .../dashboard/plan/components/plan-table.tsx | 230 +++++++++++++++--- apps/web/src/app/dashboard/plan/page.tsx | 219 ++++++++++++++++- .../register/components/Selector.tsx | 35 --- apps/web/src/app/dashboard/register/page.tsx | 130 ++++++++-- apps/web/src/components/ViewSelector.tsx | 45 ++++ .../CoursePlanSelector.tsx | 198 +++++++++++++++ .../components/CoursePlanCard.tsx | 80 ++++++ .../components/CoursePlanFilters.tsx | 92 +++++++ .../components/TermYearSelector.tsx | 144 +++++++++++ .../src/modules/course-plan-selector/index.ts | 1 + .../components/CourseSectionItem.tsx | 201 ++++++++++++--- packages/server/convex/courses.ts | 4 +- packages/server/convex/userCourses.ts | 19 +- 14 files changed, 1270 insertions(+), 129 deletions(-) delete mode 100644 apps/web/src/app/dashboard/register/components/Selector.tsx create mode 100644 apps/web/src/components/ViewSelector.tsx create mode 100644 apps/web/src/modules/course-plan-selector/CoursePlanSelector.tsx create mode 100644 apps/web/src/modules/course-plan-selector/components/CoursePlanCard.tsx create mode 100644 apps/web/src/modules/course-plan-selector/components/CoursePlanFilters.tsx create mode 100644 apps/web/src/modules/course-plan-selector/components/TermYearSelector.tsx create mode 100644 apps/web/src/modules/course-plan-selector/index.ts diff --git a/apps/web/package.json b/apps/web/package.json index 8fccfab4..dfdad3b6 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -44,6 +44,7 @@ "react": "^19.2.0", "react-day-picker": "^9.11.1", "react-dom": "^19.2.0", + "react-draggable": "^4.5.0", "sonner": "^2.0.7", "tailwind-merge": "^3.3.1", "zod": "^4.1.12" diff --git a/apps/web/src/app/dashboard/plan/components/plan-table.tsx b/apps/web/src/app/dashboard/plan/components/plan-table.tsx index 38d7486f..96c4a4f8 100644 --- a/apps/web/src/app/dashboard/plan/components/plan-table.tsx +++ b/apps/web/src/app/dashboard/plan/components/plan-table.tsx @@ -3,11 +3,23 @@ import { api } from "@albert-plus/server/convex/_generated/api"; import { useMutation } from "convex/react"; import type { FunctionReturnType } from "convex/server"; -import { FileTextIcon, SearchIcon } from "lucide-react"; +import { + FileTextIcon, + Loader2Icon, + SearchIcon, + Trash2Icon, +} from "lucide-react"; import Link from "next/link"; import { useId, useMemo, useState } from "react"; import { toast } from "sonner"; import { useCurrentTerm, useCurrentYear } from "@/components/AppConfigProvider"; +import { Button } from "@/components/ui/button"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, +} from "@/components/ui/context-menu"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { @@ -36,21 +48,41 @@ interface PlanTableProps { student: | FunctionReturnType | undefined; + isDragging?: boolean; + onCourseDrop?: ( + courseCode: string, + title: string, + year: number, + term: string, + ) => void; + onAddExternalCourse?: () => void; } type UserCourseEntry = NonNullable< FunctionReturnType >[number]; -export default function PlanTable({ courses, student }: PlanTableProps) { +export default function PlanTable({ + courses, + student, + isDragging = false, + onCourseDrop, + onAddExternalCourse, +}: PlanTableProps) { const allTerms = ["fall", "j-term", "spring", "summer"] as const; const currentTerm = useCurrentTerm(); const currentYear = useCurrentYear(); const [courseSearch, setCourseSearch] = useState(""); + const [dragOverCell, setDragOverCell] = useState<{ + year: number; + term: Term; + } | null>(null); const importUserCourses = useMutation(api.userCourses.importUserCourses); + const createUserCourse = useMutation(api.userCourses.createUserCourse); + const deleteUserCourse = useMutation(api.userCourses.deleteUserCourse); const courseSearchId = useId(); @@ -90,6 +122,28 @@ export default function PlanTable({ courses, student }: PlanTableProps) { toast.success(successMessage); }; + const handleDeleteCourse = async (userCourse: UserCourseEntry) => { + try { + await deleteUserCourse({ id: userCourse._id }); + toast.success(`${userCourse.courseCode} removed`, { + action: { + label: "Undo", + onClick: async () => { + await createUserCourse({ + courseCode: userCourse.courseCode, + title: userCourse.title, + year: userCourse.year, + term: userCourse.term, + ...(userCourse.grade && { grade: userCourse.grade }), + }); + }, + }, + }); + } catch (_error) { + toast.error("Failed to delete course"); + } + }; + // Filter courses based on search const filteredData = useMemo(() => { return courses?.filter((userCourse) => { @@ -200,6 +254,21 @@ export default function PlanTable({ courses, student }: PlanTableProps) { return map; }, [academicTimeline, filteredData]); + const yearIndexToActualYear = useMemo(() => { + const map = new Map(); + + if (academicTimeline?.termToYearIndex) { + academicTimeline.termToYearIndex.forEach((yearIndex, termKey) => { + const actualYear = parseInt(termKey.split("-")[1], 10); + const term = termKey.split("-")[0]; + const reverseKey = `${yearIndex}-${term}`; + map.set(reverseKey, actualYear); + }); + } + + return map; + }, [academicTimeline]); + // only show terms with course const visibleTerms = useMemo(() => { return allTerms.filter((term) => { @@ -212,8 +281,7 @@ export default function PlanTable({ courses, student }: PlanTableProps) { }, [allTerms, yearColumns, yearTermMap]); if (!courses) { - // TODO: add skeletons for the page - return null; + return ; } if (courses.length === 0) { @@ -247,9 +315,7 @@ export default function PlanTable({ courses, student }: PlanTableProps) { return (
- {/* Filters */}
- {/* Course search */}
@@ -266,6 +332,9 @@ export default function PlanTable({ courses, student }: PlanTableProps) {
+
@@ -310,14 +379,79 @@ export default function PlanTable({ courses, student }: PlanTableProps) { const termMap = yearTermMap.get(year); const userCourses = termMap?.get(term) ?? []; const isCurrentColumn = currentColumnKey === year; + const isDragOver = + dragOverCell?.year === year && dragOverCell?.term === term; + + const handleDragOver = ( + e: React.DragEvent, + ) => { + e.preventDefault(); + e.dataTransfer.dropEffect = "copy"; + if ( + !dragOverCell || + dragOverCell.year !== year || + dragOverCell.term !== term + ) { + setDragOverCell({ year, term }); + } + }; + + const handleDragLeave = ( + e: React.DragEvent, + ) => { + // Only clear if leaving the cell itself, not child elements + if (e.currentTarget === e.target) { + setDragOverCell(null); + } + }; + + const handleDrop = ( + e: React.DragEvent, + ) => { + e.preventDefault(); + setDragOverCell(null); + + try { + const data = JSON.parse( + e.dataTransfer.getData("application/json"), + ); + if (data.courseCode && data.title && onCourseDrop) { + const reverseKey = `${year}-${term}`; + const actualYear = + yearIndexToActualYear.get(reverseKey) ?? year; + onCourseDrop( + data.courseCode, + data.title, + actualYear, + term, + ); + } + } catch (error) { + console.error("Error parsing dropped data:", error); + } + }; + return ( + {isDragOver && ( +
+
+ Drop here to add +
+
+ )} {userCourses.length > 0 ? (
{userCourses.map((userCourse) => { @@ -327,38 +461,64 @@ export default function PlanTable({ courses, student }: PlanTableProps) { if (!userCourse.course) { return ( -
-
- - {userCourse.title} - -
-
- {userCourse.courseCode} -
-
+ + +
+
+ + {userCourse.title} + +
+
+ {userCourse.courseCode} +
+
+
+ + + handleDeleteCourse(userCourse) + } + > + + Delete + + +
); } return ( - -
- {userCourse.title} -
-
- {userCourse.course.code} -
- + + + +
+ {userCourse.title} +
+
+ {userCourse.course.code} +
+ +
+ + + handleDeleteCourse(userCourse) + } + > + + Delete + + +
); })}
diff --git a/apps/web/src/app/dashboard/plan/page.tsx b/apps/web/src/app/dashboard/plan/page.tsx index cc2a1f5c..fef4c3dc 100644 --- a/apps/web/src/app/dashboard/plan/page.tsx +++ b/apps/web/src/app/dashboard/plan/page.tsx @@ -1,13 +1,77 @@ "use client"; import { api } from "@albert-plus/server/convex/_generated/api"; -import { useConvexAuth, useQuery } from "convex/react"; +import type { Doc } from "@albert-plus/server/convex/_generated/dataModel"; +import { + useConvexAuth, + useMutation, + usePaginatedQuery, + useQuery, +} from "convex/react"; +import { ConvexError } from "convex/values"; +import { ListIcon, TableIcon } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import Draggable from "react-draggable"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import ViewSelector from "@/components/ViewSelector"; +import { useSearchParam } from "@/hooks/use-search-param"; +import { CoursePlanSelector } from "@/modules/course-plan-selector"; import PlanTable from "./components/plan-table"; const PlanPage = () => { const { isAuthenticated } = useConvexAuth(); - const courses = useQuery( + const [mobileView, setMobileView] = useState<"selector" | "table">("table"); + const [_isMobile, setIsMobile] = useState(false); + const [showAddCourseModal, setShowAddCourseModal] = useState(false); + const [isDragging, setIsDragging] = useState(false); + const draggableRef = useRef(null); + + useEffect(() => { + const checkMobile = () => { + setIsMobile(window.innerWidth < 768); + }; + + checkMobile(); + window.addEventListener("resize", checkMobile); + return () => window.removeEventListener("resize", checkMobile); + }, []); + + useEffect(() => { + const handleEscape = (e: KeyboardEvent) => { + if (e.key === "Escape" && showAddCourseModal) { + setShowAddCourseModal(false); + } + }; + + window.addEventListener("keydown", handleEscape); + return () => window.removeEventListener("keydown", handleEscape); + }, [showAddCourseModal]); + + useEffect(() => { + const handleDragStart = () => setIsDragging(true); + const handleDragEnd = () => setIsDragging(false); + + window.addEventListener("dragstart", handleDragStart); + window.addEventListener("dragend", handleDragEnd); + + return () => { + window.removeEventListener("dragstart", handleDragStart); + window.removeEventListener("dragend", handleDragEnd); + }; + }, []); + + const { searchValue, setSearchValue, debouncedSearchValue } = useSearchParam({ + paramKey: "q", + }); + + const [displayedResults, setDisplayedResults] = useState[]>( + [], + ); + const prevSearchRef = useRef(debouncedSearchValue); + + const userCourses = useQuery( api.userCourses.getUserCourses, isAuthenticated ? {} : "skip", ); @@ -16,7 +80,156 @@ const PlanPage = () => { isAuthenticated ? {} : "skip", ); - return ; + const createUserCourse = useMutation(api.userCourses.createUserCourse); + const deleteUserCourse = useMutation(api.userCourses.deleteUserCourse); + + const { results, status, loadMore } = usePaginatedQuery( + api.courses.getCourses, + isAuthenticated + ? { + level: 100, // TODO: make it configurable + query: debouncedSearchValue || undefined, + } + : "skip", + { initialNumItems: 100 }, + ); + + useEffect(() => { + if (status !== "LoadingFirstPage") { + setDisplayedResults(results); + prevSearchRef.current = debouncedSearchValue; + } + }, [results, debouncedSearchValue, status]); + + const isSearching = + status === "LoadingFirstPage" && + prevSearchRef.current !== debouncedSearchValue && + prevSearchRef.current !== ""; + + const handleMobileViewChange = (view: "selector" | "table") => { + setMobileView(view); + }; + + const handleCourseDrop = async ( + courseCode: string, + title: string, + year: number, + term: string, + ) => { + try { + const id = await createUserCourse({ + courseCode, + title, + year, + term: term as "spring" | "summer" | "fall" | "j-term", + }); + toast.success(`${courseCode} added to ${term} ${year}`, { + action: { + label: "Undo", + onClick: () => deleteUserCourse({ id }), + }, + }); + } catch (error) { + const errorMessage = + error instanceof ConvexError + ? (error.data as string) + : error instanceof Error + ? error.message + : "Unexpected error occurred"; + toast.error(errorMessage); + } + }; + + return ( +
+ {/* Mobile toggle buttons */} +
+ +
+ + {/* Mobile view */} +
+ {mobileView === "selector" ? ( + + ) : ( +
+ +
+ )} +
+ + {/* Desktop view */} +
+
+
+ { + setShowAddCourseModal(true); + }} + /> +
+
+ + {showAddCourseModal && ( + +
+
+

Add From Albert

+ +
+
+
+ +
+
+
+
+ )} +
+
+ ); }; export default PlanPage; diff --git a/apps/web/src/app/dashboard/register/components/Selector.tsx b/apps/web/src/app/dashboard/register/components/Selector.tsx deleted file mode 100644 index 04239abe..00000000 --- a/apps/web/src/app/dashboard/register/components/Selector.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { CalendarIcon, ListIcon } from "lucide-react"; - -import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; -import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; - -interface SelectorProps { - value: "selector" | "calendar"; - onValueChange: (value: "selector" | "calendar") => void; -} - -export default function Selector({ value, onValueChange }: SelectorProps) { - const handleValueChange = (newValue: string) => { - if (newValue === "selector" || newValue === "calendar") { - onValueChange(newValue); - } - }; - - return ( - - - - - - - - - - - - ); -} diff --git a/apps/web/src/app/dashboard/register/page.tsx b/apps/web/src/app/dashboard/register/page.tsx index 4289f626..55359866 100644 --- a/apps/web/src/app/dashboard/register/page.tsx +++ b/apps/web/src/app/dashboard/register/page.tsx @@ -2,9 +2,12 @@ import { api } from "@albert-plus/server/convex/_generated/api"; import { useConvexAuth, usePaginatedQuery, useQuery } from "convex/react"; +import { CalendarIcon, ListIcon } from "lucide-react"; import { useEffect, useRef, useState } from "react"; -import Selector from "@/app/dashboard/register/components/Selector"; import { useNextTerm, useNextYear } from "@/components/AppConfigProvider"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import ViewSelector from "@/components/ViewSelector"; import { useSearchParam } from "@/hooks/use-search-param"; import { CourseSelector } from "@/modules/course-selection"; import CourseSelectorSkeleton from "@/modules/course-selection/components/CourseSelectorSkeleton"; @@ -13,6 +16,7 @@ import type { CourseOfferingWithCourse, } from "@/modules/course-selection/types"; import { + type Class, getUserClassesByTerm, ScheduleCalendar, } from "@/modules/schedule-calendar/schedule-calendar"; @@ -25,9 +29,50 @@ const RegisterPage = () => { const [hoveredCourse, setHoveredCourse] = useState( null, ); + const [selectedCourse, setSelectedCourse] = useState(null); const [mobileView, setMobileView] = useState<"selector" | "calendar">( "selector", ); + const [previousMobileView, setPreviousMobileView] = useState< + "selector" | "calendar" + >("selector"); + const [isMobile, setIsMobile] = useState(false); + + // TODO: save the state to cookie + const [showAlternatives, setShowAlternatives] = useState(true); + + useEffect(() => { + const checkMobile = () => { + setIsMobile(window.innerWidth < 768); + }; + + checkMobile(); + window.addEventListener("resize", checkMobile); + return () => window.removeEventListener("resize", checkMobile); + }, []); + + useEffect(() => { + if (selectedCourse && isMobile && mobileView === "calendar") { + setPreviousMobileView("calendar"); + setMobileView("selector"); + } + }, [selectedCourse, isMobile, mobileView]); + + const handleCourseSelect = (course: Class | null) => { + if (!course && isMobile && previousMobileView === "calendar") { + // When closing detail panel on mobile, return to calendar view + setMobileView("calendar"); + } + setSelectedCourse(course); + }; + + // clear selected course when switching tabs + const handleMobileViewChange = (view: "selector" | "calendar") => { + setMobileView(view); + if (view === "calendar" && selectedCourse) { + setSelectedCourse(null); + } + }; // Search param state with debouncing and URL sync const { searchValue, setSearchValue, debouncedSearchValue } = useSearchParam({ @@ -65,7 +110,16 @@ const RegisterPage = () => { } }, [results, debouncedSearchValue, status]); - const classes = getUserClassesByTerm(allClasses, currentYear, currentTerm); + const allClassesForTerm = getUserClassesByTerm( + allClasses, + currentYear, + currentTerm, + ); + + // Filter out alternatives if toggle is off + const classes = showAlternatives + ? allClassesForTerm + : allClassesForTerm?.filter((c) => !c.alternativeOf); const isSearching = status === "LoadingFirstPage" && @@ -81,11 +135,35 @@ const RegisterPage = () => { return ; } + const AltToggle = () => ( + <> + +
+ +

+ You can set one course as alternative for another. +

+
+ + ); + return (
{/* Mobile toggle buttons */}
- +
{/* Mobile view */} @@ -99,29 +177,51 @@ const RegisterPage = () => { loadMore={loadMore} status={status} isSearching={isSearching} + selectedCourse={selectedCourse} + onCourseSelect={handleCourseSelect} /> ) : ( -
- +
+
+ +
+
)}
{/* Desktop view */}
- +
+
+ +
+ +
- +
diff --git a/apps/web/src/components/ViewSelector.tsx b/apps/web/src/components/ViewSelector.tsx new file mode 100644 index 00000000..60d0829d --- /dev/null +++ b/apps/web/src/components/ViewSelector.tsx @@ -0,0 +1,45 @@ +import type { LucideIcon } from "lucide-react"; +import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; + +interface Tab { + value: T; + label: string; + icon: LucideIcon; +} + +interface ViewSelectorProps { + value: T; + onValueChange: (value: T) => void; + tabs: Tab[]; +} + +export default function ViewSelector({ + value, + onValueChange, + tabs, +}: ViewSelectorProps) { + const handleValueChange = (newValue: string) => { + onValueChange(newValue as T); + }; + + return ( + + + + {tabs.map((tab) => ( + + + ))} + + + + + ); +} diff --git a/apps/web/src/modules/course-plan-selector/CoursePlanSelector.tsx b/apps/web/src/modules/course-plan-selector/CoursePlanSelector.tsx new file mode 100644 index 00000000..b40d31a2 --- /dev/null +++ b/apps/web/src/modules/course-plan-selector/CoursePlanSelector.tsx @@ -0,0 +1,198 @@ +"use client"; +import { api } from "@albert-plus/server/convex/_generated/api"; +import type { Doc } from "@albert-plus/server/convex/_generated/dataModel"; +import { useVirtualizer } from "@tanstack/react-virtual"; +import { useMutation } from "convex/react"; +import type { FunctionReturnType } from "convex/server"; +import { ConvexError } from "convex/values"; +import React, { useState } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { useSearchParam } from "@/hooks/use-search-param"; +import CoursePlanCard from "./components/CoursePlanCard"; +import CoursePlanFilters from "./components/CoursePlanFilters"; +import TermYearSelector from "./components/TermYearSelector"; + +interface CoursePlanSelectorProps { + courses: Doc<"courses">[]; + student: + | FunctionReturnType + | undefined; + onSearchChange: (search: string) => void; + searchQuery: string; + loadMore: (numItems: number) => void; + status: "LoadingFirstPage" | "CanLoadMore" | "LoadingMore" | "Exhausted"; + isSearching?: boolean; +} + +const CoursePlanSelector = ({ + courses, + student, + onSearchChange, + searchQuery, + loadMore, + status, + isSearching = false, +}: CoursePlanSelectorProps) => { + const { searchValue: filtersParam, setSearchValue: setFiltersParam } = + useSearchParam({ paramKey: "filters", debounceDelay: 0 }); + + const [creditFilter, setCreditFilter] = useState(null); + const [selectedCourse, setSelectedCourse] = useState | null>( + null, + ); + + const createUserCourse = useMutation(api.userCourses.createUserCourse); + const deleteUserCourse = useMutation(api.userCourses.deleteUserCourse); + + const isFiltersExpanded = filtersParam === "true"; + + const handleToggleFilters = () => { + setFiltersParam(isFiltersExpanded ? "" : "true"); + }; + + const filteredCourses = creditFilter + ? courses.filter((course) => course.credits === creditFilter) + : courses; + + const availableCredits = Array.from( + new Set(courses.map((course) => course.credits)), + ).sort((a, b) => a - b); + + const parentRef = React.useRef(null); + + const rowVirtualizer = useVirtualizer({ + count: filteredCourses.length, + getScrollElement: () => parentRef.current, + estimateSize: () => 80, + overscan: 5, + gap: 8, + }); + + const handleCourseAdd = async ( + courseCode: string, + title: string, + year: number, + term: string, + ) => { + try { + const id = await createUserCourse({ + courseCode, + title, + year, + term: term as "spring" | "summer" | "fall" | "j-term", + }); + toast.success(`${courseCode} added to ${term} ${year}`, { + action: { + label: "Undo", + onClick: () => deleteUserCourse({ id }), + }, + }); + setSelectedCourse(null); + } catch (error) { + const errorMessage = + error instanceof ConvexError + ? (error.data as string) + : error instanceof Error + ? error.message + : "Unexpected error occurred"; + toast.error(errorMessage); + } + }; + + return ( +
+
+ +
+ + {filteredCourses.length === 0 && !isSearching && ( +
+

No courses found.

+ +
+ )} + + {filteredCourses.length === 0 && isSearching && ( +
+

Searching...

+
+ )} + + {filteredCourses.length > 0 && ( +
+
+ {rowVirtualizer.getVirtualItems().map((virtualItem) => { + const course = filteredCourses[virtualItem.index]; + + return ( +
+ setSelectedCourse(course)} + /> +
+ ); + })} +
+
+ )} + + {status === "CanLoadMore" && ( +
+ +
+ )} + {status === "LoadingMore" && ( +
+

Loading more courses...

+
+ )} + + {selectedCourse && ( + setSelectedCourse(null)} + /> + )} +
+ ); +}; + +export default CoursePlanSelector; diff --git a/apps/web/src/modules/course-plan-selector/components/CoursePlanCard.tsx b/apps/web/src/modules/course-plan-selector/components/CoursePlanCard.tsx new file mode 100644 index 00000000..34945031 --- /dev/null +++ b/apps/web/src/modules/course-plan-selector/components/CoursePlanCard.tsx @@ -0,0 +1,80 @@ +import type { Doc } from "@albert-plus/server/convex/_generated/dataModel"; +import { GripVertical, PlusIcon } from "lucide-react"; +import { Button } from "@/components/ui/button"; + +interface CoursePlanCardProps { + course: Doc<"courses">; + onAdd: () => void; +} + +const CoursePlanCard = ({ course, onAdd }: CoursePlanCardProps) => { + const handleDragStart = (e: React.DragEvent) => { + e.dataTransfer.effectAllowed = "copy"; + e.dataTransfer.setData( + "application/json", + JSON.stringify({ + courseCode: course.code, + title: course.title, + }), + ); + if (e.currentTarget instanceof HTMLElement) { + e.currentTarget.style.opacity = "0.5"; + } + }; + + const handleDragEnd = (e: React.DragEvent) => { + if (e.currentTarget instanceof HTMLElement) { + e.currentTarget.style.opacity = "1"; + } + }; + + // TODO: finish this + const SCHOOLABBR = { + "College of Arts and Science": "CAS", + "Liberal Studies": "LS", + "Tandon School of Engineering": "Tandon", + }; + + return ( +
+
+ + +
+
+ ); +}; + +export default CoursePlanCard; diff --git a/apps/web/src/modules/course-plan-selector/components/CoursePlanFilters.tsx b/apps/web/src/modules/course-plan-selector/components/CoursePlanFilters.tsx new file mode 100644 index 00000000..517bd099 --- /dev/null +++ b/apps/web/src/modules/course-plan-selector/components/CoursePlanFilters.tsx @@ -0,0 +1,92 @@ +import clsx from "clsx"; +import { SlidersHorizontal } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +interface CoursePlanFiltersProps { + searchInput: string; + onSearchChange: (value: string) => void; + creditFilter: number | null; + onCreditFilterChange: (credit: number | null) => void; + availableCredits: number[]; + isExpanded: boolean; + onToggleExpand: () => void; +} + +const CoursePlanFilters = ({ + searchInput, + onSearchChange, + creditFilter, + onCreditFilterChange, + availableCredits, + isExpanded, + onToggleExpand, +}: CoursePlanFiltersProps) => { + const hasActiveFilters = creditFilter !== null; + + return ( +
+
+ +
+ onSearchChange(e.target.value)} + className="flex-1" + /> + +
+
+ + {isExpanded && ( +
+ +
+ + {availableCredits.map((credit) => ( + + ))} +
+
+ )} +
+ ); +}; + +export default CoursePlanFilters; diff --git a/apps/web/src/modules/course-plan-selector/components/TermYearSelector.tsx b/apps/web/src/modules/course-plan-selector/components/TermYearSelector.tsx new file mode 100644 index 00000000..1d449795 --- /dev/null +++ b/apps/web/src/modules/course-plan-selector/components/TermYearSelector.tsx @@ -0,0 +1,144 @@ +import type { api } from "@albert-plus/server/convex/_generated/api"; +import type { Doc } from "@albert-plus/server/convex/_generated/dataModel"; +import type { FunctionReturnType } from "convex/server"; +import type { KeyboardEvent } from "react"; +import { useId, useMemo, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +interface TermYearSelectorProps { + course: Doc<"courses">; + student: + | FunctionReturnType + | undefined; + onConfirm: ( + courseCode: string, + title: string, + year: number, + term: string, + ) => void; + onClose: () => void; +} + +const TERMS = [ + { value: "spring", label: "Spring" }, + { value: "summer", label: "Summer" }, + { value: "fall", label: "Fall" }, + { value: "j-term", label: "J-Term" }, +]; + +const TermYearSelector = ({ + course, + student, + onConfirm, + onClose, +}: TermYearSelectorProps) => { + const dialogTitleId = useId(); + const currentYear = new Date().getFullYear(); + const [selectedTerm, setSelectedTerm] = useState("fall"); + const [selectedYear, setSelectedYear] = useState(currentYear); + + // Generate year options based on student's academic timeline + const yearOptions = useMemo(() => { + if (student?.startingDate && student?.expectedGraduationDate) { + const startYear = student.startingDate.year; + const endYear = student.expectedGraduationDate.year; + const numYears = endYear - startYear + 1; + return Array.from({ length: numYears }, (_, i) => startYear + i); + } + // Fallback to current year +/- 2 if no student data + return Array.from({ length: 5 }, (_, i) => currentYear - 2 + i); + }, [student, currentYear]); + + const handleConfirm = () => { + onConfirm(course.code, course.title, selectedYear, selectedTerm); + }; + + const handleDialogKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.stopPropagation(); + onClose(); + } + }; + + return ( +
+ + +
+
+ + ); +}; + +export default TermYearSelector; diff --git a/apps/web/src/modules/course-plan-selector/index.ts b/apps/web/src/modules/course-plan-selector/index.ts new file mode 100644 index 00000000..df16ff1c --- /dev/null +++ b/apps/web/src/modules/course-plan-selector/index.ts @@ -0,0 +1 @@ +export { default as CoursePlanSelector } from "./CoursePlanSelector"; diff --git a/apps/web/src/modules/course-selection/components/CourseSectionItem.tsx b/apps/web/src/modules/course-selection/components/CourseSectionItem.tsx index 8b9bf894..b875bb21 100644 --- a/apps/web/src/modules/course-selection/components/CourseSectionItem.tsx +++ b/apps/web/src/modules/course-selection/components/CourseSectionItem.tsx @@ -1,58 +1,183 @@ +"use client"; + +import { api } from "@albert-plus/server/convex/_generated/api"; +import type { Id } from "@albert-plus/server/convex/_generated/dataModel"; import clsx from "clsx"; +import { useQuery } from "convex/react"; +import { CalendarPlus, ChevronDownIcon, GitBranch } from "lucide-react"; +import { useState } from "react"; +import { Button } from "@/components/ui/button"; + +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import type { CourseOffering } from "../types"; interface CourseSectionItemProps { offering: CourseOffering; onSelect?: (offering: CourseOffering) => void; + onSelectAsAlternative?: ( + offering: CourseOffering, + alternativeOf: Id<"userCourseOfferings">, + ) => void; onHover?: (offering: CourseOffering | null) => void; } export const CourseSectionItem = ({ offering, onSelect, + onSelectAsAlternative, onHover, }: CourseSectionItemProps) => { + const [showSelector, setShowSelector] = useState(false); + const [dropdownOpen, setDropdownOpen] = useState(false); + const [selectedCourseId, setSelectedCourseId] = + useState | null>(null); + + const handleClick = () => { + if (offering.status === "closed") return; + setShowSelector(true); + }; + + const handleAddToCalendar = () => { + onSelect?.(offering); + setShowSelector(false); + }; + + const handleAddAsAlternative = (courseId?: Id<"userCourseOfferings">) => { + const idToUse = courseId || selectedCourseId; + if (idToUse) { + onSelectAsAlternative?.(offering, idToUse); + setDropdownOpen(false); + setShowSelector(false); + setSelectedCourseId(null); + } + }; + + const userCourses = useQuery(api.userCourseOfferings.getUserCourseOfferings); + + // Filter out courses that are alternatives themselves + const mainCourses = userCourses?.filter((course) => !course.alternativeOf); + + if (!mainCourses || mainCourses.length === 0) { + return ( + + ); + } + return ( - + + + + + + + {mainCourses.map((course) => ( + handleAddAsAlternative(course._id)} + className="flex flex-col items-start gap-1 py-2 cursor-pointer" + > + + {course.courseOffering.courseCode} -{" "} + {course.courseOffering.title} + + + Section {course.courseOffering.section.toUpperCase()} •{" "} + {course.courseOffering.instructor.join(", ")} + + + ))} + + + + + )} - + ); }; diff --git a/packages/server/convex/courses.ts b/packages/server/convex/courses.ts index f887e3f4..8b4ee9e9 100644 --- a/packages/server/convex/courses.ts +++ b/packages/server/convex/courses.ts @@ -97,7 +97,7 @@ export const getCourses = protectedQuery({ return { page: allCourses, isDone: results.every((result) => result.isDone), - continueCursor: continueCursor ?? null, + continueCursor: continueCursor ?? "", }; } @@ -121,7 +121,7 @@ export const getCourses = protectedQuery({ return { page: allCourses, isDone: results.every((result) => result.isDone), - continueCursor: continueCursor ?? null, + continueCursor: continueCursor ?? "", }; } diff --git a/packages/server/convex/userCourses.ts b/packages/server/convex/userCourses.ts index f4e7e303..df3721cb 100644 --- a/packages/server/convex/userCourses.ts +++ b/packages/server/convex/userCourses.ts @@ -1,4 +1,4 @@ -import { v } from "convex/values"; +import { ConvexError, v } from "convex/values"; import { omit } from "convex-helpers"; import { getOneFrom } from "convex-helpers/server/relationships"; import { partial } from "convex-helpers/validators"; @@ -35,6 +35,23 @@ export const getUserCourses = protectedQuery({ export const createUserCourse = protectedMutation({ args: omit(userCourses, ["userId"]), handler: async (ctx, args) => { + const existing = await ctx.db + .query("userCourses") + .withIndex("by_user_course_term", (q) => + q + .eq("userId", ctx.user.subject) + .eq("courseCode", args.courseCode) + .eq("year", args.year) + .eq("term", args.term), + ) + .first(); + + if (existing) { + throw new ConvexError( + `${args.courseCode} already exists in ${args.term} ${args.year}`, + ); + } + return await ctx.db.insert("userCourses", { userId: ctx.user.subject, ...args, From 966173ef3542eac1d8756581f772f77901bbd3b0 Mon Sep 17 00:00:00 2001 From: Kang Jiaming Date: Mon, 10 Nov 2025 02:58:34 -0500 Subject: [PATCH 02/12] chore: update bun.lock after cherry-pick --- bun.lock | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/bun.lock b/bun.lock index 5c5933ed..caf37ac4 100644 --- a/bun.lock +++ b/bun.lock @@ -96,6 +96,7 @@ "react": "^19.2.0", "react-day-picker": "^9.11.1", "react-dom": "^19.2.0", + "react-draggable": "^4.5.0", "sonner": "^2.0.7", "tailwind-merge": "^3.3.1", "zod": "^4.1.12", @@ -1779,6 +1780,8 @@ "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], + "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], + "lowercase-keys": ["lowercase-keys@3.0.0", "", {}, "sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ=="], "lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], @@ -2083,6 +2086,8 @@ "prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="], + "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], + "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], "proto-list": ["proto-list@1.2.4", "", {}, "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA=="], @@ -2109,6 +2114,8 @@ "react-dom": ["react-dom@19.2.0", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ=="], + "react-draggable": ["react-draggable@4.5.0", "", { "dependencies": { "clsx": "^2.1.1", "prop-types": "^15.8.1" }, "peerDependencies": { "react": ">= 16.3.0", "react-dom": ">= 16.3.0" } }, "sha512-VC+HBLEZ0XJxnOxVAZsdRi8rD04Iz3SiiKOoYzamjylUcju/hP9np/aZdLHf/7WOD268WMoNJMvYfB5yAK45cw=="], + "react-error-overlay": ["react-error-overlay@6.0.9", "", {}, "sha512-nQTTcUu+ATDbrSD1BZHr5kgSD4oF8OFjxun8uAaL8RwPBacGBNPf/yAuVVdx17N8XNzRDMrZ9XcKZHCjPW+9ew=="], "react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], From 2c9de02bb9fa7597aa7f4393ee41931001e76b20 Mon Sep 17 00:00:00 2001 From: Kang Jiaming Date: Mon, 10 Nov 2025 03:05:15 -0500 Subject: [PATCH 03/12] fix: cherrypick --- apps/web/src/app/dashboard/register/page.tsx | 120 +++---------------- 1 file changed, 14 insertions(+), 106 deletions(-) diff --git a/apps/web/src/app/dashboard/register/page.tsx b/apps/web/src/app/dashboard/register/page.tsx index 55359866..39926791 100644 --- a/apps/web/src/app/dashboard/register/page.tsx +++ b/apps/web/src/app/dashboard/register/page.tsx @@ -5,8 +5,6 @@ import { useConvexAuth, usePaginatedQuery, useQuery } from "convex/react"; import { CalendarIcon, ListIcon } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { useNextTerm, useNextYear } from "@/components/AppConfigProvider"; -import { Label } from "@/components/ui/label"; -import { Switch } from "@/components/ui/switch"; import ViewSelector from "@/components/ViewSelector"; import { useSearchParam } from "@/hooks/use-search-param"; import { CourseSelector } from "@/modules/course-selection"; @@ -16,7 +14,6 @@ import type { CourseOfferingWithCourse, } from "@/modules/course-selection/types"; import { - type Class, getUserClassesByTerm, ScheduleCalendar, } from "@/modules/schedule-calendar/schedule-calendar"; @@ -29,50 +26,9 @@ const RegisterPage = () => { const [hoveredCourse, setHoveredCourse] = useState( null, ); - const [selectedCourse, setSelectedCourse] = useState(null); const [mobileView, setMobileView] = useState<"selector" | "calendar">( "selector", ); - const [previousMobileView, setPreviousMobileView] = useState< - "selector" | "calendar" - >("selector"); - const [isMobile, setIsMobile] = useState(false); - - // TODO: save the state to cookie - const [showAlternatives, setShowAlternatives] = useState(true); - - useEffect(() => { - const checkMobile = () => { - setIsMobile(window.innerWidth < 768); - }; - - checkMobile(); - window.addEventListener("resize", checkMobile); - return () => window.removeEventListener("resize", checkMobile); - }, []); - - useEffect(() => { - if (selectedCourse && isMobile && mobileView === "calendar") { - setPreviousMobileView("calendar"); - setMobileView("selector"); - } - }, [selectedCourse, isMobile, mobileView]); - - const handleCourseSelect = (course: Class | null) => { - if (!course && isMobile && previousMobileView === "calendar") { - // When closing detail panel on mobile, return to calendar view - setMobileView("calendar"); - } - setSelectedCourse(course); - }; - - // clear selected course when switching tabs - const handleMobileViewChange = (view: "selector" | "calendar") => { - setMobileView(view); - if (view === "calendar" && selectedCourse) { - setSelectedCourse(null); - } - }; // Search param state with debouncing and URL sync const { searchValue, setSearchValue, debouncedSearchValue } = useSearchParam({ @@ -110,16 +66,7 @@ const RegisterPage = () => { } }, [results, debouncedSearchValue, status]); - const allClassesForTerm = getUserClassesByTerm( - allClasses, - currentYear, - currentTerm, - ); - - // Filter out alternatives if toggle is off - const classes = showAlternatives - ? allClassesForTerm - : allClassesForTerm?.filter((c) => !c.alternativeOf); + const classes = getUserClassesByTerm(allClasses, currentYear, currentTerm); const isSearching = status === "LoadingFirstPage" && @@ -135,30 +82,13 @@ const RegisterPage = () => { return ; } - const AltToggle = () => ( - <> - -
- -

- You can set one course as alternative for another. -

-
- - ); - return (
{/* Mobile toggle buttons */}
{ loadMore={loadMore} status={status} isSearching={isSearching} - selectedCourse={selectedCourse} - onCourseSelect={handleCourseSelect} /> ) : ( -
-
- -
- +
+
)}
{/* Desktop view */}
-
-
- -
- -
+
- +
From b41b1aee0a2089c70b0322e0984c82e9b384b8ef Mon Sep 17 00:00:00 2001 From: Kang Jiaming Date: Sun, 7 Dec 2025 17:00:38 -0500 Subject: [PATCH 04/12] finish the abbreviation list --- .../components/CoursePlanCard.tsx | 25 ++++++++++++++++--- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/apps/web/src/modules/course-plan-selector/components/CoursePlanCard.tsx b/apps/web/src/modules/course-plan-selector/components/CoursePlanCard.tsx index 34945031..467679f8 100644 --- a/apps/web/src/modules/course-plan-selector/components/CoursePlanCard.tsx +++ b/apps/web/src/modules/course-plan-selector/components/CoursePlanCard.tsx @@ -28,11 +28,28 @@ const CoursePlanCard = ({ course, onAdd }: CoursePlanCardProps) => { } }; - // TODO: finish this const SCHOOLABBR = { "College of Arts and Science": "CAS", + "Graduate School of Arts and Science": "GSAS", + "College of Dentistry": "Dentistry", + "Gallatin School of Individualized Study": "Gallatin", + "Leonard N. Stern School of Business": "Stern", "Liberal Studies": "LS", + "NYU Abu Dhabi": "NYUAD", + "NYU Shanghai": "NYUSH", + "NYU Grossman School of Medicine": "Grossman", + "NYU Grossman Long Island School of Medicine": "LISOM", + "Robert F. Wagner Graduate School of Public Service": "Wagner", + "Rory Meyers College of Nursing": "Nursing", + "School of Global Public Health": "GPH", + "School of Law": "Law", + "School of Professional Studies": "SPS", + "Silver School of Social Work": "Silver", + "Steinhardt School of Culture, Education, and Human Development": + "Steinhardt", "Tandon School of Engineering": "Tandon", + "Tisch School of the Arts": "Tisch", + "Non-School Based Programs - UG": "NSB", }; return ( @@ -57,17 +74,17 @@ const CoursePlanCard = ({ course, onAdd }: CoursePlanCardProps) => { - {/*@ts-expect-error: abbreviation list is not finished, and we have a fallback to use original name*/} - {SCHOOLABBR[course.school] ?? course.school} + {SCHOOLABBR[course.school] ?? course.school.split(" ")[0]}
+ {/* on mobile we display add button, desktop can just drag and drop */} - - )} {status === "LoadingMore" && (

Loading more courses...

From 633ce250704b4538fd4ad2227290ceeaf3530c8d Mon Sep 17 00:00:00 2001 From: Kang Jiaming Date: Sun, 7 Dec 2025 20:21:31 -0500 Subject: [PATCH 08/12] fix: internal drag course within the table not working --- .../dashboard/plan/components/plan-table.tsx | 126 +++++++++++++++--- .../CoursePlanSelector.tsx | 12 +- 2 files changed, 116 insertions(+), 22 deletions(-) diff --git a/apps/web/src/app/dashboard/plan/components/plan-table.tsx b/apps/web/src/app/dashboard/plan/components/plan-table.tsx index db17c1ad..a05b6ce8 100644 --- a/apps/web/src/app/dashboard/plan/components/plan-table.tsx +++ b/apps/web/src/app/dashboard/plan/components/plan-table.tsx @@ -1,6 +1,7 @@ "use client"; import { api } from "@albert-plus/server/convex/_generated/api"; +import type { Id } from "@albert-plus/server/convex/_generated/dataModel"; import { useMutation } from "convex/react"; import type { FunctionReturnType } from "convex/server"; import { @@ -84,6 +85,7 @@ export default function PlanTable({ const importUserCourses = useMutation(api.userCourses.importUserCourses); const createUserCourse = useMutation(api.userCourses.createUserCourse); const deleteUserCourse = useMutation(api.userCourses.deleteUserCourse); + const updateUserCourse = useMutation(api.userCourses.updateUserCourse); const updateStudent = useMutation(api.students.updateCurrentStudent); @@ -279,6 +281,38 @@ export default function PlanTable({ return map; }, [academicTimeline]); + const setDragPayload = ( + e: React.DragEvent, + payload: Record, + ) => { + const serialized = JSON.stringify(payload); + e.dataTransfer.setData("application/json", serialized); + e.dataTransfer.setData("text/plain", serialized); + }; + + const handleInternalDragStart = ( + e: React.DragEvent, + userCourse: UserCourseEntry, + ) => { + e.dataTransfer.effectAllowed = "move"; + setDragPayload(e, { + userCourseId: userCourse._id, + courseCode: userCourse.course?.code ?? userCourse.courseCode, + title: userCourse.title, + term: userCourse.term, + year: userCourse.year, + }); + if (e.currentTarget instanceof HTMLElement) { + e.currentTarget.style.opacity = "0.6"; + } + }; + + const handleInternalDragEnd = (e: React.DragEvent) => { + if (e.currentTarget instanceof HTMLElement) { + e.currentTarget.style.opacity = "1"; + } + }; + // only show terms with course const visibleTerms = useMemo(() => { return allTerms.filter((term) => { @@ -396,7 +430,7 @@ export default function PlanTable({ e: React.DragEvent, ) => { e.preventDefault(); - e.dataTransfer.dropEffect = "copy"; + e.dataTransfer.dropEffect = "move"; if ( !dragOverCell || dragOverCell.year !== year || @@ -421,23 +455,64 @@ export default function PlanTable({ e.preventDefault(); setDragOverCell(null); + const rawData = + e.dataTransfer.getData("application/json") || + e.dataTransfer.getData("text/plain"); + if (!rawData) { + return; + } + + let data: { + userCourseId?: string; + courseCode?: string; + title?: string; + term?: string; + year?: number; + }; + try { - const data = JSON.parse( - e.dataTransfer.getData("application/json"), - ); - if (data.courseCode && data.title && onCourseDrop) { - const reverseKey = `${year}-${term}`; - const actualYear = - yearIndexToActualYear.get(reverseKey) ?? year; - onCourseDrop( - data.courseCode, - data.title, - actualYear, - term, - ); - } + data = JSON.parse(rawData); } catch (error) { - console.error("Error parsing dropped data:", error); + console.error(error); + return; + } + + const reverseKey = `${year}-${term}`; + const actualYear = + yearIndexToActualYear.get(reverseKey) ?? year; + + if (data?.userCourseId) { + if ( + data.term === term && + data.year && + data.year === actualYear + ) { + return; + } + + updateUserCourse({ + id: data.userCourseId as Id<"userCourses">, + term, + year: actualYear, + }) + .then(() => { + toast.success( + `${data.courseCode ?? "Course"} moved to ${term} ${actualYear}`, + ); + }) + .catch(() => { + toast.error("Failed to move course"); + }); + return; + } + + if (data?.courseCode && data?.title && onCourseDrop) { + onCourseDrop( + data.courseCode, + data.title, + actualYear, + term, + ); } }; @@ -473,7 +548,15 @@ export default function PlanTable({ return ( -
+ + handleInternalDragStart(e, userCourse) } + onDragEnd={handleInternalDragEnd} + className="block p-2 border rounded-md bg-card hover:bg-muted/50 transition-colors cursor-grab active:cursor-grabbing" >
{userCourse.title} diff --git a/apps/web/src/modules/course-plan-selector/CoursePlanSelector.tsx b/apps/web/src/modules/course-plan-selector/CoursePlanSelector.tsx index f4d6433e..c1c636ac 100644 --- a/apps/web/src/modules/course-plan-selector/CoursePlanSelector.tsx +++ b/apps/web/src/modules/course-plan-selector/CoursePlanSelector.tsx @@ -78,10 +78,18 @@ const CoursePlanSelector = ({ return; } - if (lastItem.index >= filteredCourses.length - 1 && status === "CanLoadMore") { + if ( + lastItem.index >= filteredCourses.length - 1 && + status === "CanLoadMore" + ) { loadMore(200); } - }, [status, loadMore, filteredCourses.length, rowVirtualizer.getVirtualItems()]); + }, [ + status, + loadMore, + filteredCourses.length, + rowVirtualizer.getVirtualItems(), + ]); const handleCourseAdd = async ( courseCode: string, From b108ef7df2afcf04ae55fcf6bf11d36d62a1891c Mon Sep 17 00:00:00 2001 From: Kang Jiaming Date: Sun, 7 Dec 2025 20:30:03 -0500 Subject: [PATCH 09/12] fix: performance issues --- apps/web/src/hooks/use-search-param.ts | 9 ++++ .../CoursePlanSelector.tsx | 43 ++++++++++++------- .../course-selection/CourseSelector.tsx | 14 +++--- 3 files changed, 45 insertions(+), 21 deletions(-) diff --git a/apps/web/src/hooks/use-search-param.ts b/apps/web/src/hooks/use-search-param.ts index 0429013f..e21c29ca 100644 --- a/apps/web/src/hooks/use-search-param.ts +++ b/apps/web/src/hooks/use-search-param.ts @@ -21,6 +21,15 @@ export function useSearchParam(options: UseSearchParamOptions) { // Update URL with debounced search value useEffect(() => { + const currentValue = searchParams.get(paramKey) ?? ""; + + if ( + (debouncedSearchValue === "" && currentValue === "") || + debouncedSearchValue === currentValue + ) { + return; + } + const params = new URLSearchParams(searchParams); if (debouncedSearchValue) { params.set(paramKey, debouncedSearchValue); diff --git a/apps/web/src/modules/course-plan-selector/CoursePlanSelector.tsx b/apps/web/src/modules/course-plan-selector/CoursePlanSelector.tsx index c1c636ac..91354f55 100644 --- a/apps/web/src/modules/course-plan-selector/CoursePlanSelector.tsx +++ b/apps/web/src/modules/course-plan-selector/CoursePlanSelector.tsx @@ -55,9 +55,24 @@ const CoursePlanSelector = ({ ? courses.filter((course) => course.credits === creditFilter) : courses; - const availableCredits = Array.from( - new Set(courses.map((course) => course.credits)), - ).sort((a, b) => a - b); + const [availableCredits, setAvailableCredits] = useState([]); + + useEffect(() => { + if (status === "LoadingFirstPage") { + setAvailableCredits([]); + return; + } + + setAvailableCredits((prev) => { + if (prev.length > 0) { + return prev; + } + const credits = Array.from( + new Set(courses.map((course) => course.credits)), + ).sort((a, b) => a - b); + return credits; + }); + }, [courses, status]); const parentRef = React.useRef(null); @@ -69,27 +84,23 @@ const CoursePlanSelector = ({ gap: 8, }); - // https://tanstack.com/virtual/latest/docs/framework/react/examples/infinite-scroll - // biome-ignore lint/correctness/useExhaustiveDependencies: It's in Tanstack doc + const virtualItems = rowVirtualizer.getVirtualItems(); + useEffect(() => { - const [lastItem] = [...rowVirtualizer.getVirtualItems()].reverse(); + if (status !== "CanLoadMore") { + return; + } + + const [lastItem] = [...virtualItems].reverse(); if (!lastItem) { return; } - if ( - lastItem.index >= filteredCourses.length - 1 && - status === "CanLoadMore" - ) { + if (lastItem.index >= filteredCourses.length - 1) { loadMore(200); } - }, [ - status, - loadMore, - filteredCourses.length, - rowVirtualizer.getVirtualItems(), - ]); + }, [status, loadMore, filteredCourses.length, virtualItems]); const handleCourseAdd = async ( courseCode: string, diff --git a/apps/web/src/modules/course-selection/CourseSelector.tsx b/apps/web/src/modules/course-selection/CourseSelector.tsx index 565f423b..6d837cdb 100644 --- a/apps/web/src/modules/course-selection/CourseSelector.tsx +++ b/apps/web/src/modules/course-selection/CourseSelector.tsx @@ -77,23 +77,27 @@ const CourseSelector = ({ gap: 8, }); + const virtualItems = rowVirtualizer.getVirtualItems(); + useEffect(() => { onHover?.(hoveredSection); }, [hoveredSection, onHover]); - // https://tanstack.com/virtual/latest/docs/framework/react/examples/infinite-scroll - // biome-ignore lint/correctness/useExhaustiveDependencies: It's in Tanstack doc useEffect(() => { - const [lastItem] = [...rowVirtualizer.getVirtualItems()].reverse(); + if (status !== "CanLoadMore") { + return; + } + + const [lastItem] = [...virtualItems].reverse(); if (!lastItem) { return; } - if (lastItem.index >= filteredData.length - 1 && status === "CanLoadMore") { + if (lastItem.index >= filteredData.length - 1) { loadMore(200); } - }, [status, loadMore, filteredData.length, rowVirtualizer.getVirtualItems()]); + }, [status, loadMore, filteredData.length, virtualItems]); const handleSectionSelect = async (offering: CourseOffering) => { if (offering.status === "closed") { From a9c86d4c4e4d526b26dca3851c33cee315ff08eb Mon Sep 17 00:00:00 2001 From: Kang Jiaming Date: Sun, 7 Dec 2025 20:40:40 -0500 Subject: [PATCH 10/12] fix DND and capitalize toast message --- .../dashboard/plan/components/plan-table.tsx | 27 +++++++++++-------- apps/web/src/app/dashboard/plan/page.tsx | 13 +++++---- .../CoursePlanSelector.tsx | 13 +++++---- 3 files changed, 32 insertions(+), 21 deletions(-) diff --git a/apps/web/src/app/dashboard/plan/components/plan-table.tsx b/apps/web/src/app/dashboard/plan/components/plan-table.tsx index a05b6ce8..69b2b946 100644 --- a/apps/web/src/app/dashboard/plan/components/plan-table.tsx +++ b/apps/web/src/app/dashboard/plan/components/plan-table.tsx @@ -294,7 +294,7 @@ export default function PlanTable({ e: React.DragEvent, userCourse: UserCourseEntry, ) => { - e.dataTransfer.effectAllowed = "move"; + e.dataTransfer.effectAllowed = "copyMove"; setDragPayload(e, { userCourseId: userCourse._id, courseCode: userCourse.course?.code ?? userCourse.courseCode, @@ -430,7 +430,7 @@ export default function PlanTable({ e: React.DragEvent, ) => { e.preventDefault(); - e.dataTransfer.dropEffect = "move"; + e.dataTransfer.dropEffect = "copy"; if ( !dragOverCell || dragOverCell.year !== year || @@ -455,13 +455,6 @@ export default function PlanTable({ e.preventDefault(); setDragOverCell(null); - const rawData = - e.dataTransfer.getData("application/json") || - e.dataTransfer.getData("text/plain"); - if (!rawData) { - return; - } - let data: { userCourseId?: string; courseCode?: string; @@ -471,12 +464,24 @@ export default function PlanTable({ }; try { + const rawData = + e.dataTransfer.getData("application/json") || + e.dataTransfer.getData("text/plain"); + if (!rawData) { + e.dataTransfer.dropEffect = "none"; + return; + } data = JSON.parse(rawData); } catch (error) { - console.error(error); + console.error("Error parsing dropped data:", error); return; } + const isInternalDrag = Boolean(data?.userCourseId); + e.dataTransfer.dropEffect = isInternalDrag + ? "move" + : "copy"; + const reverseKey = `${year}-${term}`; const actualYear = yearIndexToActualYear.get(reverseKey) ?? year; @@ -497,7 +502,7 @@ export default function PlanTable({ }) .then(() => { toast.success( - `${data.courseCode ?? "Course"} moved to ${term} ${actualYear}`, + `${data.courseCode ?? "Course"} moved to ${term.charAt(0).toUpperCase() + term.slice(1)} ${actualYear}`, ); }) .catch(() => { diff --git a/apps/web/src/app/dashboard/plan/page.tsx b/apps/web/src/app/dashboard/plan/page.tsx index 76ee46b0..2771a689 100644 --- a/apps/web/src/app/dashboard/plan/page.tsx +++ b/apps/web/src/app/dashboard/plan/page.tsx @@ -123,12 +123,15 @@ const PlanPage = () => { year, term: term as "spring" | "summer" | "fall" | "j-term", }); - toast.success(`${courseCode} added to ${term} ${year}`, { - action: { - label: "Undo", - onClick: () => deleteUserCourse({ id }), + toast.success( + `${courseCode} added to ${term.charAt(0).toUpperCase() + term.slice(1)} ${year}`, + { + action: { + label: "Undo", + onClick: () => deleteUserCourse({ id }), + }, }, - }); + ); } catch (error) { const errorMessage = error instanceof ConvexError diff --git a/apps/web/src/modules/course-plan-selector/CoursePlanSelector.tsx b/apps/web/src/modules/course-plan-selector/CoursePlanSelector.tsx index 91354f55..60c521eb 100644 --- a/apps/web/src/modules/course-plan-selector/CoursePlanSelector.tsx +++ b/apps/web/src/modules/course-plan-selector/CoursePlanSelector.tsx @@ -115,12 +115,15 @@ const CoursePlanSelector = ({ year, term: term as "spring" | "summer" | "fall" | "j-term", }); - toast.success(`${courseCode} added to ${term} ${year}`, { - action: { - label: "Undo", - onClick: () => deleteUserCourse({ id }), + toast.success( + `${courseCode} added to ${term.charAt(0).toUpperCase() + term.slice(1)} ${year}`, + { + action: { + label: "Undo", + onClick: () => deleteUserCourse({ id }), + }, }, - }); + ); setSelectedCourse(null); } catch (error) { const errorMessage = From 0e2656739ae1ee3424e8fd2c8f06c9145488473d Mon Sep 17 00:00:00 2001 From: Kang Jiaming Date: Mon, 8 Dec 2025 10:47:28 -0500 Subject: [PATCH 11/12] improve UX, use dropEffect move when move within the table --- .../src/app/dashboard/plan/components/plan-table.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/web/src/app/dashboard/plan/components/plan-table.tsx b/apps/web/src/app/dashboard/plan/components/plan-table.tsx index 69b2b946..b6edb034 100644 --- a/apps/web/src/app/dashboard/plan/components/plan-table.tsx +++ b/apps/web/src/app/dashboard/plan/components/plan-table.tsx @@ -294,7 +294,8 @@ export default function PlanTable({ e: React.DragEvent, userCourse: UserCourseEntry, ) => { - e.dataTransfer.effectAllowed = "copyMove"; + e.dataTransfer.effectAllowed = "move"; + e.dataTransfer.setData("application/x-albert-internal", "true"); setDragPayload(e, { userCourseId: userCourse._id, courseCode: userCourse.course?.code ?? userCourse.courseCode, @@ -430,7 +431,10 @@ export default function PlanTable({ e: React.DragEvent, ) => { e.preventDefault(); - e.dataTransfer.dropEffect = "copy"; + const isInternal = e.dataTransfer.types.includes( + "application/x-albert-internal", + ); + e.dataTransfer.dropEffect = isInternal ? "move" : "copy"; if ( !dragOverCell || dragOverCell.year !== year || @@ -545,9 +549,7 @@ export default function PlanTable({ {userCourses.length > 0 ? (
{userCourses.map((userCourse) => { - const key = userCourse.course - ? `${year}-${term}-${userCourse.course.code}` - : `${year}-${term}-${userCourse._id}`; + const key = userCourse._id; if (!userCourse.course) { return ( From 8fb122c2f568057e30883fee7603ee5de2d8b945 Mon Sep 17 00:00:00 2001 From: Kang Jiaming Date: Tue, 9 Dec 2025 21:58:10 -0500 Subject: [PATCH 12/12] fix: logic in displaying alternatives if no user course in db --- .../components/CourseSectionItem.tsx | 45 +++++++++++-------- 1 file changed, 27 insertions(+), 18 deletions(-) diff --git a/apps/web/src/modules/course-selection/components/CourseSectionItem.tsx b/apps/web/src/modules/course-selection/components/CourseSectionItem.tsx index 2b3e29fe..f625f0d4 100644 --- a/apps/web/src/modules/course-selection/components/CourseSectionItem.tsx +++ b/apps/web/src/modules/course-selection/components/CourseSectionItem.tsx @@ -64,15 +64,9 @@ export const CourseSectionItem = ({ const userCourses = useQuery(api.userCourseOfferings.getUserCourseOfferings); // Filter out courses that are alternatives themselves - const mainCourses = userCourses?.filter((course) => !course.alternativeOf); - - if (!mainCourses || mainCourses.length === 0) { - return ( - - ); - } + const mainCourses = + userCourses?.filter((course) => !course.alternativeOf) ?? []; + const hasMainCourses = mainCourses.length > 0; return ( <> @@ -168,6 +162,7 @@ export const CourseSectionItem = ({ - {mainCourses.map((course) => ( + {hasMainCourses ? ( + mainCourses.map((course) => ( + handleAddAsAlternative(course._id)} + className="flex flex-col items-start gap-1 py-2 cursor-pointer" + > + + {course.courseOffering.courseCode} -{" "} + {course.courseOffering.title} + + + Section {course.courseOffering.section.toUpperCase()}{" "} + • {course.courseOffering.instructors.join(", ")} + + + )) + ) : ( handleAddAsAlternative(course._id)} - className="flex flex-col items-start gap-1 py-2 cursor-pointer" + disabled + className="flex flex-col items-start gap-1 py-2" > - {course.courseOffering.courseCode} -{" "} - {course.courseOffering.title} + Add a course to set alternatives - Section {course.courseOffering.section.toUpperCase()} •{" "} - {course.courseOffering.instructors.join(", ")} + No courses available yet - ))} + )}