diff --git a/apps/web/src/app/dashboard/register/page.tsx b/apps/web/src/app/dashboard/register/page.tsx index d1a1d1be..10a70201 100644 --- a/apps/web/src/app/dashboard/register/page.tsx +++ b/apps/web/src/app/dashboard/register/page.tsx @@ -5,6 +5,8 @@ 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"; @@ -14,6 +16,7 @@ import type { CourseOfferingWithCourse, } from "@/modules/course-selection/types"; import { + type Class, getUserClassesByTerm, ScheduleCalendar, } from "@/modules/schedule-calendar/schedule-calendar"; @@ -26,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({ @@ -68,7 +112,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" && @@ -84,13 +137,32 @@ const RegisterPage = () => { return ; } + const AltToggle = () => ( + <> + +
+ +

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

+
+ + ); + return (
{/* Mobile toggle buttons */}
+ handleMobileViewChange(val as "selector" | "calendar") + } tabs={[ { value: "selector", label: "Courses", icon: ListIcon }, { value: "calendar", label: "Schedule", icon: CalendarIcon }, @@ -109,31 +181,53 @@ const RegisterPage = () => { loadMore={loadMore} status={status} isSearching={isSearching} + selectedCourse={selectedCourse} + onCourseSelect={handleCourseSelect} selectedClassNumbers={selectedClassNumbers} /> ) : ( -
- +
+
+ +
+
)}
{/* Desktop view */}
- +
+
+ +
+ +
- +
diff --git a/apps/web/src/hooks/use-search-param.ts b/apps/web/src/hooks/use-search-param.ts index e21c29ca..ab393782 100644 --- a/apps/web/src/hooks/use-search-param.ts +++ b/apps/web/src/hooks/use-search-param.ts @@ -1,5 +1,5 @@ import { useRouter, useSearchParams } from "next/navigation"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useDebounce } from "./use-debounce"; interface UseSearchParamOptions { @@ -19,25 +19,23 @@ export function useSearchParam(options: UseSearchParamOptions) { ); const debouncedSearchValue = useDebounce(searchValue, debounceDelay); + // Track the last URL-synced value to prevent infinite loops + const lastSyncedValue = useRef(searchParams.get(paramKey)); + // 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); - } else { - params.delete(paramKey); + // Only update if the debounced value differs from what's already in the URL + if (debouncedSearchValue !== lastSyncedValue.current) { + const params = new URLSearchParams(window.location.search); + if (debouncedSearchValue) { + params.set(paramKey, debouncedSearchValue); + } else { + params.delete(paramKey); + } + lastSyncedValue.current = debouncedSearchValue || null; + router.replace(`?${params.toString()}`, { scroll: false }); } - router.replace(`?${params.toString()}`, { scroll: false }); - }, [debouncedSearchValue, router, searchParams, paramKey]); + }, [debouncedSearchValue, paramKey, router]); return { searchValue, diff --git a/apps/web/src/modules/course-plan-selector/CoursePlanSelector.tsx b/apps/web/src/modules/course-plan-selector/CoursePlanSelector.tsx index 60c521eb..8c468b54 100644 --- a/apps/web/src/modules/course-plan-selector/CoursePlanSelector.tsx +++ b/apps/web/src/modules/course-plan-selector/CoursePlanSelector.tsx @@ -1,11 +1,10 @@ "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, { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { useSearchParam } from "@/hooks/use-search-param"; @@ -74,33 +73,24 @@ const CoursePlanSelector = ({ }); }, [courses, status]); - const parentRef = React.useRef(null); - - const rowVirtualizer = useVirtualizer({ - count: filteredCourses.length, - getScrollElement: () => parentRef.current, - estimateSize: () => 80, - overscan: 5, - gap: 8, - }); - - const virtualItems = rowVirtualizer.getVirtualItems(); + const observerTarget = useRef(null); useEffect(() => { - if (status !== "CanLoadMore") { - return; - } - - const [lastItem] = [...virtualItems].reverse(); - - if (!lastItem) { - return; + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting && status === "CanLoadMore") { + loadMore(200); + } + }, + { threshold: 0.1 }, + ); + + if (observerTarget.current) { + observer.observe(observerTarget.current); } - if (lastItem.index >= filteredCourses.length - 1) { - loadMore(200); - } - }, [status, loadMore, filteredCourses.length, virtualItems]); + return () => observer.disconnect(); + }, [status, loadMore]); const handleCourseAdd = async ( courseCode: string, @@ -172,37 +162,15 @@ const CoursePlanSelector = ({ )} {filteredCourses.length > 0 && ( -
-
- {rowVirtualizer.getVirtualItems().map((virtualItem) => { - const course = filteredCourses[virtualItem.index]; - - return ( -
- setSelectedCourse(course)} - /> -
- ); - })} -
+
+ {filteredCourses.map((course) => ( + setSelectedCourse(course)} + /> + ))} +
)} diff --git a/apps/web/src/modules/course-selection/CourseSelector.tsx b/apps/web/src/modules/course-selection/CourseSelector.tsx index 6d837cdb..da719926 100644 --- a/apps/web/src/modules/course-selection/CourseSelector.tsx +++ b/apps/web/src/modules/course-selection/CourseSelector.tsx @@ -1,13 +1,14 @@ "use client"; import { api } from "@albert-plus/server/convex/_generated/api"; -import { useVirtualizer } from "@tanstack/react-virtual"; -import { useMutation } from "convex/react"; +import type { Id } from "@albert-plus/server/convex/_generated/dataModel"; +import { useMutation, useQuery } from "convex/react"; import { ConvexError } from "convex/values"; -import React, { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { useSearchParam } from "@/hooks/use-search-param"; -import { CourseCard, CourseFilters } from "./components"; +import { type Class, CourseDetailPanel } from "@/modules/schedule-calendar"; +import { ConflictDialog, CourseCard, CourseFilters } from "./components"; import { useCourseExpansion, useCourseFiltering } from "./hooks"; import type { CourseOffering, CourseOfferingWithCourse } from "./types"; @@ -19,6 +20,8 @@ interface CourseSelectorComponentProps { loadMore: (numItems: number) => void; status: "LoadingFirstPage" | "CanLoadMore" | "LoadingMore" | "Exhausted"; isSearching?: boolean; + selectedCourse?: Class | null; + onCourseSelect?: (course: Class | null) => void; selectedClassNumbers?: number[]; } @@ -30,6 +33,8 @@ const CourseSelector = ({ loadMore, status, isSearching = false, + selectedCourse, + onCourseSelect, selectedClassNumbers, }: CourseSelectorComponentProps) => { const { searchValue: filtersParam, setSearchValue: setFiltersParam } = @@ -49,10 +54,28 @@ const CourseSelector = ({ api.userCourseOfferings.removeUserCourseOffering, ); + const swapWithAlternative = useMutation( + api.userCourseOfferings.swapWithAlternative, + ); + const [hoveredSection, setHoveredSection] = useState( null, ); + const [conflictState, setConflictState] = useState<{ + course: CourseOffering | null; + conflictingClassNumbers: number[]; + } | null>(null); + + const [isAddingWithConflict, setIsAddingWithConflict] = useState(false); + + const conflictingCourses = useQuery( + api.userCourseOfferings.getCourseOfferingsByClassNumbers, + conflictState?.conflictingClassNumbers + ? { classNumbers: conflictState.conflictingClassNumbers } + : "skip", + ); + const isFiltersExpanded = filtersParam === "true"; const handleToggleFilters = () => { @@ -67,61 +90,218 @@ const CourseSelector = ({ setHoveredSection(section); }; - const parentRef = React.useRef(null); + const observerTarget = useRef(null); - const rowVirtualizer = useVirtualizer({ - count: filteredData.length, - getScrollElement: () => parentRef.current, - estimateSize: () => 100, - overscan: 5, - gap: 8, - }); + useEffect(() => { + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting && status === "CanLoadMore") { + loadMore(200); + } + }, + { threshold: 0.1 }, + ); + + if (observerTarget.current) { + observer.observe(observerTarget.current); + } - const virtualItems = rowVirtualizer.getVirtualItems(); + return () => observer.disconnect(); + }, [status, loadMore]); useEffect(() => { onHover?.(hoveredSection); }, [hoveredSection, onHover]); - useEffect(() => { - if (status !== "CanLoadMore") { + const handleSectionSelect = async (offering: CourseOffering) => { + if (offering.status === "closed") { + toast.error("This section is closed."); return; } + setHoveredSection(null); + try { + const id = await addCourseOffering({ classNumber: offering.classNumber }); + toast.success(`${offering.courseCode} ${offering.section} added`, { + action: { + label: "Undo", + onClick: () => removeCourseOffering({ id }), + }, + }); + } catch (error) { + if (error instanceof ConvexError) { + const errorData = error.data as + | string + | { type: string; conflictingClassNumbers: number[] }; - const [lastItem] = [...virtualItems].reverse(); - - if (!lastItem) { - return; - } + if ( + typeof errorData === "object" && + errorData.type === "TIME_CONFLICT" + ) { + setConflictState({ + course: offering, + conflictingClassNumbers: errorData.conflictingClassNumbers, + }); + return; + } - if (lastItem.index >= filteredData.length - 1) { - loadMore(200); + toast.error( + typeof errorData === "string" ? errorData : "An error occurred", + ); + } else { + toast.error("Unexpected error occurred"); + } } - }, [status, loadMore, filteredData.length, virtualItems]); + }; - const handleSectionSelect = async (offering: CourseOffering) => { + const handleSectionSelectAsAlternative = async ( + offering: CourseOffering, + alternativeOf: Id<"userCourseOfferings">, + ) => { if (offering.status === "closed") { toast.error("This section is closed."); return; } setHoveredSection(null); try { - const id = await addCourseOffering({ classNumber: offering.classNumber }); - toast.success(`${offering.courseCode} ${offering.section} added`, { + const id = await addCourseOffering({ + classNumber: offering.classNumber, + alternativeOf, + }); + toast.success( + `${offering.courseCode} ${offering.section} added as alternative`, + { + action: { + label: "Undo", + onClick: () => removeCourseOffering({ id }), + }, + }, + ); + } catch (error) { + const errorMessage = + error instanceof ConvexError + ? (error.data as string) + : "Unexpected error occurred"; + toast.error(errorMessage); + } + }; + + const handleDelete = async ( + id: Id<"userCourseOfferings">, + classNumber: number, + title: string, + alternativeOf?: Id<"userCourseOfferings">, + ) => { + try { + await removeCourseOffering({ id }); + toast.success(`${title} removed`, { + action: { + label: "Undo", + onClick: () => + addCourseOffering( + alternativeOf ? { classNumber, alternativeOf } : { classNumber }, + ), + }, + }); + } catch (error) { + const errorMessage = + error instanceof ConvexError + ? (error.data as string) + : "Unexpected error occurred"; + toast.error(errorMessage); + } + }; + + const handleSwap = async (alternativeId: Id<"userCourseOfferings">) => { + try { + await swapWithAlternative({ alternativeId }); + } catch (error) { + const errorMessage = + error instanceof ConvexError + ? (error.data as string) + : "Unexpected error occurred"; + toast.error(errorMessage); + } + }; + + const handleConflictAddAsMain = async () => { + if (!conflictState?.course) return; + + setIsAddingWithConflict(true); + try { + const classNumber = conflictState.course.classNumber; + const courseCode = conflictState.course.courseCode; + const section = conflictState.course.section; + const id = await addCourseOffering({ + classNumber, + forceAdd: true, + }); + toast.success(`${courseCode} ${section} added`, { action: { label: "Undo", onClick: () => removeCourseOffering({ id }), }, }); + setConflictState(null); + } catch (error) { + const errorMessage = + error instanceof ConvexError + ? (error.data as string) + : "Unexpected error occurred"; + toast.error(errorMessage); + } finally { + setIsAddingWithConflict(false); + } + }; + + const handleConflictAddAsAlternative = async ( + alternativeOf: Id<"userCourseOfferings">, + ) => { + if (!conflictState?.course) return; + + setIsAddingWithConflict(true); + try { + const id = await addCourseOffering({ + classNumber: conflictState.course.classNumber, + alternativeOf, + }); + toast.success( + `${conflictState.course.courseCode} ${conflictState.course.section} added as alternative`, + { + action: { + label: "Undo", + onClick: () => removeCourseOffering({ id }), + }, + }, + ); + setConflictState(null); } catch (error) { const errorMessage = error instanceof ConvexError ? (error.data as string) : "Unexpected error occurred"; toast.error(errorMessage); + } finally { + setIsAddingWithConflict(false); } }; + const handleConflictCancel = () => { + setConflictState(null); + }; + + if (selectedCourse) { + return ( +
+ onCourseSelect?.(null)} + onDelete={handleDelete} + onSwap={handleSwap} + /> +
+ ); + } + return (
@@ -164,41 +344,20 @@ const CourseSelector = ({ )} {filteredData.length > 0 && ( -
-
- {rowVirtualizer.getVirtualItems().map((virtualItem) => { - const course = filteredData[virtualItem.index]; - - return ( -
- -
- ); - })} -
+
+ {filteredData.map((course) => ( + + ))} +
)} @@ -207,6 +366,19 @@ const CourseSelector = ({

Loading more courses...

)} + + { + if (!open) setConflictState(null); + }} + newCourse={conflictState?.course ?? null} + conflictingCourses={conflictingCourses?.filter((c) => c !== null) ?? []} + onAddAsMain={handleConflictAddAsMain} + onAddAsAlternative={handleConflictAddAsAlternative} + onCancel={handleConflictCancel} + isAdding={isAddingWithConflict} + />
); }; diff --git a/apps/web/src/modules/course-selection/components/CourseCard.tsx b/apps/web/src/modules/course-selection/components/CourseCard.tsx index 164ec182..3983263d 100644 --- a/apps/web/src/modules/course-selection/components/CourseCard.tsx +++ b/apps/web/src/modules/course-selection/components/CourseCard.tsx @@ -1,3 +1,4 @@ +import type { Id } from "@albert-plus/server/convex/_generated/dataModel"; import clsx from "clsx"; import { ChevronDown, ChevronRight, InfoIcon } from "lucide-react"; import { @@ -21,6 +22,10 @@ interface CourseCardProps { selectedClassNumbers?: number[]; onToggleExpand: (courseCode: string) => void; onSectionSelect?: (offering: CourseOffering) => void; + onSectionSelectAsAlternative?: ( + offering: CourseOffering, + alternativeOf: Id<"userCourseOfferings">, + ) => void; onSectionHover?: (offering: CourseOffering | null) => void; } @@ -30,6 +35,7 @@ export const CourseCard = ({ selectedClassNumbers, onToggleExpand, onSectionSelect, + onSectionSelectAsAlternative, onSectionHover, }: CourseCardProps) => { return ( @@ -91,6 +97,7 @@ export const CourseCard = ({ offering={offering} selectedClassNumbers={selectedClassNumbers} onSelect={onSectionSelect} + onSelectAsAlternative={onSectionSelectAsAlternative} onHover={onSectionHover} /> ))} diff --git a/apps/web/src/modules/course-selection/components/CourseSectionItem.tsx b/apps/web/src/modules/course-selection/components/CourseSectionItem.tsx index f625f0d4..e1d3b3ab 100644 --- a/apps/web/src/modules/course-selection/components/CourseSectionItem.tsx +++ b/apps/web/src/modules/course-selection/components/CourseSectionItem.tsx @@ -6,8 +6,8 @@ import clsx from "clsx"; import { useQuery } from "convex/react"; import { CalendarPlus, ChevronDownIcon, GitBranch } from "lucide-react"; import { useState } from "react"; +import { useNextTerm, useNextYear } from "@/components/AppConfigProvider"; import { Button } from "@/components/ui/button"; - import { DropdownMenu, DropdownMenuContent, @@ -34,13 +34,14 @@ export const CourseSectionItem = ({ onSelectAsAlternative, onHover, }: CourseSectionItemProps) => { + const term = useNextTerm(); + const year = useNextYear(); + const [showSelector, setShowSelector] = useState(false); const [dropdownOpen, setDropdownOpen] = useState(false); const [selectedCourseId, setSelectedCourseId] = useState | null>(null); - const isSelected = selectedClassNumbers?.includes(offering.classNumber); - const handleClick = () => { if (offering.status === "closed") return; setShowSelector(true); @@ -63,10 +64,17 @@ export const CourseSectionItem = ({ const userCourses = useQuery(api.userCourseOfferings.getUserCourseOfferings); - // Filter out courses that are alternatives themselves + // Filter out courses that are alternatives themselves and only show courses from the same term/year const mainCourses = - userCourses?.filter((course) => !course.alternativeOf) ?? []; + userCourses?.filter( + (course) => + !course.alternativeOf && + course.courseOffering.term === term && + course.courseOffering.year === year, + ) ?? []; + const hasMainCourses = mainCourses.length > 0; + const isSelected = selectedClassNumbers?.includes(offering.classNumber); return ( <> diff --git a/apps/web/src/modules/course-selection/components/alternative-dropdown.tsx b/apps/web/src/modules/course-selection/components/alternative-dropdown.tsx new file mode 100644 index 00000000..5544474c --- /dev/null +++ b/apps/web/src/modules/course-selection/components/alternative-dropdown.tsx @@ -0,0 +1,72 @@ +"use client"; + +import { api } from "@albert-plus/server/convex/_generated/api"; +import type { Id } from "@albert-plus/server/convex/_generated/dataModel"; +import { useQuery } from "convex/react"; +import { ChevronDownIcon } from "lucide-react"; +import { useNextTerm, useNextYear } from "@/components/AppConfigProvider"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; + +interface AlternativeDropdownProps { + onSelect: (userCourseOfferingId: Id<"userCourseOfferings">) => void; +} + +export function AlternativeDropdown({ onSelect }: AlternativeDropdownProps) { + const term = useNextTerm(); + const year = useNextYear(); + const userCourses = useQuery(api.userCourseOfferings.getUserCourseOfferings); + + // Filter out courses that are alternatives themselves and only show courses from the same term/year + const mainCourses = userCourses?.filter( + (course) => + !course.alternativeOf && + course.courseOffering.term === term && + course.courseOffering.year === year, + ); + + if (!mainCourses || mainCourses.length === 0) { + return ( + + ); + } + + return ( + + + + + + {mainCourses.map((course) => ( + onSelect(course._id)} + className="flex flex-col items-start gap-1 py-2" + > + + {course.courseOffering.courseCode} - {course.courseOffering.title} + + + Section {course.courseOffering.section.toUpperCase()} •{" "} + {course.courseOffering.instructors.join(", ")} + + + ))} + + + ); +} diff --git a/apps/web/src/modules/course-selection/components/conflict-dialog.tsx b/apps/web/src/modules/course-selection/components/conflict-dialog.tsx new file mode 100644 index 00000000..edfbfb7d --- /dev/null +++ b/apps/web/src/modules/course-selection/components/conflict-dialog.tsx @@ -0,0 +1,227 @@ +"use client"; + +import { api } from "@albert-plus/server/convex/_generated/api"; +import type { Id } from "@albert-plus/server/convex/_generated/dataModel"; +import { useQuery } from "convex/react"; +import { ChevronDown, CircleAlertIcon, GitBranch } from "lucide-react"; +import { useState } from "react"; +import { useNextTerm, useNextYear } from "@/components/AppConfigProvider"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import type { CourseOffering } from "../types"; + +type ConflictDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + newCourse: CourseOffering | null; + conflictingCourses: CourseOffering[]; + onAddAsMain: () => void; + onAddAsAlternative: (alternativeOf: Id<"userCourseOfferings">) => void; + onCancel: () => void; + isAdding?: boolean; +}; + +export default function ConflictDialog({ + open, + onOpenChange, + newCourse, + conflictingCourses, + onAddAsMain, + onAddAsAlternative, + onCancel, + isAdding = false, +}: ConflictDialogProps) { + const term = useNextTerm(); + const year = useNextYear(); + const [dropdownOpen, setDropdownOpen] = useState(false); + + const userCourses = useQuery(api.userCourseOfferings.getUserCourseOfferings); + + const conflictingMainCourses = userCourses?.filter( + (course) => + !course.alternativeOf && + course.courseOffering.term === term && + course.courseOffering.year === year && + conflictingCourses.some( + (c) => c.classNumber === course.courseOffering.classNumber, + ), + ); + + const handleOpenChange = (newOpen: boolean) => { + onOpenChange(newOpen); + if (!newOpen) { + onCancel(); + } + }; + + const handleAddAsAlternative = (courseId: Id<"userCourseOfferings">) => { + setDropdownOpen(false); + // Give dropdown time to close and clean up before async operation + setTimeout(() => { + onAddAsAlternative(courseId); + }, 0); + }; + + if (!newCourse) return null; + + return ( + + + + + Schedule Conflict Detected + + +

+ The course you're trying to add conflicts with{" "} + {conflictingCourses.length} existing course + {conflictingCourses.length !== 1 ? "s" : ""} in your schedule. +

+
+
+ + +
+
+

+ Course to Add +

+
+
+
+ + {newCourse.courseCode} + + + Section {newCourse.section.toUpperCase()} + +
+

{newCourse.title}

+

+ {newCourse.days + .map((day) => day.charAt(0).toUpperCase() + day.slice(1)) + .join(", ")}{" "} + • {newCourse.startTime} - {newCourse.endTime} +

+
+
+
+ + {/* Conflicting Courses */} +
+

+ Conflicting Course{conflictingCourses.length !== 1 ? "s" : ""} +

+
+ {conflictingCourses.map((course) => ( +
+
+
+ + {course.courseCode} + + + Section {course.section.toUpperCase()} + +
+

{course.title}

+

+ {course.days + .map( + (day) => day.charAt(0).toUpperCase() + day.slice(1), + ) + .join(", ")}{" "} + • {course.startTime} - {course.endTime} +

+
+
+ ))} +
+
+
+
+ + +
+ + + + + + {conflictingMainCourses?.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(", ")} + + + ))} + + + +
+ + + +
+
+
+ ); +} diff --git a/apps/web/src/modules/course-selection/components/course-detail-panel.tsx b/apps/web/src/modules/course-selection/components/course-detail-panel.tsx new file mode 100644 index 00000000..43f07779 --- /dev/null +++ b/apps/web/src/modules/course-selection/components/course-detail-panel.tsx @@ -0,0 +1,329 @@ +"use client"; + +import { api } from "@albert-plus/server/convex/_generated/api"; +import type { Id } from "@albert-plus/server/convex/_generated/dataModel"; +import { useQuery } from "convex/react"; +import { format } from "date-fns"; +import { ArrowLeft, Trash2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Separator } from "@/components/ui/separator"; +import type { Class } from "../../schedule-calendar/schedule-calendar"; + +interface CourseDetailPanelProps { + course: Class | null; + onClose: () => void; + onDelete: ( + id: Id<"userCourseOfferings">, + classNumber: number, + title: string, + alternativeOf?: Id<"userCourseOfferings">, + ) => void; + onSwap?: (alternativeId: Id<"userCourseOfferings">) => void; +} + +export function CourseDetailPanel({ + course, + onClose, + onDelete, + onSwap, +}: CourseDetailPanelProps) { + const alternatives = useQuery( + api.userCourseOfferings.getAlternativeCourses, + course?.userCourseOfferingId + ? { + userCourseOfferingId: + course.userCourseOfferingId as Id<"userCourseOfferings">, + } + : "skip", + ); + + // Get the main course info if this course is an alternative + const allUserCourses = useQuery( + api.userCourseOfferings.getUserCourseOfferings, + ); + const currentCourse = course?.userCourseOfferingId + ? allUserCourses?.find((c) => c._id === course.userCourseOfferingId) + : null; + + const alternativeOfId: Id<"userCourseOfferings"> | null = currentCourse + ? (currentCourse.alternativeOf as Id<"userCourseOfferings"> | null) + : course?.alternativeOf + ? (course.alternativeOf as Id<"userCourseOfferings">) + : null; + + const mainCourse = alternativeOfId + ? allUserCourses?.find((c) => c._id === alternativeOfId) + : null; + + if (!course) return null; + + const handleDelete = () => { + if (course.userCourseOfferingId && course.classNumber) { + onDelete( + course.userCourseOfferingId as Id<"userCourseOfferings">, + course.classNumber, + course.title, + alternativeOfId ?? undefined, + ); + onClose(); + } + }; + + const formatTimeSlot = (slot: { start: Date; end: Date }) => { + return `${format(slot.start, "EEEE, h:mm a")} - ${format(slot.end, "h:mm a")}`; + }; + + const formatTerm = (term: string, year: number) => { + const termMap: Record = { + spring: "Spring", + summer: "Summer", + fall: "Fall", + "j-term": "J-Term", + }; + return `${termMap[term] || term} ${year}`; + }; + + const getStatusBadgeColor = (status: string) => { + switch (status) { + case "open": + return "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400"; + case "closed": + return "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400"; + case "waitlist": + return "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400"; + default: + return "bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400"; + } + }; + + return ( +
+ {/* Header */} +
+ +

Course Details

+
+ + +
+
+

+ {/* + schedule-calendar:171 is making title`${offering.courseCode} - ${offering.title}` + extract the title only here, or remove the courseCode addition in calendar if safe +*/} + {course.title.split(" - ").slice(1).join(" - ") || course.title} +

+
+ +
+
+
+

+ Course Code +

+

+ {course.courseCode} +

+
+ + {course.classNumber && ( +
+

+ Class Number +

+

+ {course.classNumber} +

+
+ )} + +
+

Section

+

+ {course.section.toUpperCase()} +

+
+ +
+

Term

+

+ {formatTerm(course.term, course.year)} +

+
+
+ +
+

+ Instructor +

+

+ {course.instructors.join(", ") || "TBA"} +

+
+ + {course.location && ( +
+

+ Location +

+

+ {course.location} +

+
+ )} + +
+

Schedule

+
+ {course.times.map((slot, index) => ( +

+ {formatTimeSlot(slot)} +

+ ))} +
+
+ +
+

Status

+
+ + {course.status.charAt(0).toUpperCase() + + course.status.slice(1)} + + {course.status === "waitlist" && + course.waitlistNum !== undefined && ( + + ({course.waitlistNum} on waitlist) + + )} +
+
+ + {course.isCorequisite && ( +
+

+ Corequisite +

+

+ {course.corequisiteOf + ? `Corequisite of class ${course.corequisiteOf}` + : "This is a corequisite course"} +

+
+ )} +
+ + {/* Alternatives Section */} + {alternatives && alternatives.length > 0 && ( + <> + +
+

+ Alternative Courses Added by You +

+
+ {alternatives.map((alt) => ( +
+
+
+

+ {alt.courseOffering.courseCode} -{" "} + {alt.courseOffering.title} +

+

+ Section {alt.courseOffering.section.toUpperCase()} •{" "} + {alt.courseOffering.instructors.join(", ")} +

+
+ + {alt.courseOffering.status.charAt(0).toUpperCase() + + alt.courseOffering.status.slice(1)} + +
+
+ {alt.courseOffering.days + .map( + (day) => day.charAt(0).toUpperCase() + day.slice(1), + ) + .join(", ")}{" "} + • {alt.courseOffering.startTime} -{" "} + {alt.courseOffering.endTime} +
+ +
+ ))} +
+
+ + )} + + {/* Alternative Course Info */} + {mainCourse && ( +
+

+ This course is an alternative of{" "} + + {mainCourse.courseOffering.title} + +

+ +
+ )} +
+
+ + {!course.isPreview && course.userCourseOfferingId && ( +
+ +
+ )} +
+ ); +} diff --git a/apps/web/src/modules/course-selection/components/index.ts b/apps/web/src/modules/course-selection/components/index.ts index 06effb2b..8e3f585c 100644 --- a/apps/web/src/modules/course-selection/components/index.ts +++ b/apps/web/src/modules/course-selection/components/index.ts @@ -1,5 +1,7 @@ +export { AlternativeDropdown } from "./alternative-dropdown"; export { CourseCard } from "./CourseCard"; export { CourseFilters } from "./CourseFilters"; export { CourseSectionItem } from "./CourseSectionItem"; +export { default as ConflictDialog } from "./conflict-dialog"; export type { DayOptionValue } from "./DaysOfWeek"; export { DEFAULT_SELECTED_DAYS, default as DaysOfWeek } from "./DaysOfWeek"; diff --git a/apps/web/src/modules/schedule-calendar/components/event-item.tsx b/apps/web/src/modules/schedule-calendar/components/event-item.tsx index 29bf58c3..cf873771 100644 --- a/apps/web/src/modules/schedule-calendar/components/event-item.tsx +++ b/apps/web/src/modules/schedule-calendar/components/event-item.tsx @@ -181,14 +181,21 @@ export function EventItem({ "py-1 flex flex-col h-full relative", durationMinutes < 45 ? "items-center" : "items-start", "text-[10px] sm:text-xs", - isHovered && !event.isPreview && "scale-105 shadow-lg z-50", + isHovered && + !event.isPreview && + !event.isAlternative && + "scale-105 shadow-lg z-50", event.isPreview && "opacity-50 z-40", + event.isAlternative && "opacity-40 z-30", className, )} > {event.isPreview && (
)} + {event.isAlternative && ( +
+ )} {durationMinutes < 45 ? (
{getDisplayTitle(event.title)}{" "} diff --git a/apps/web/src/modules/schedule-calendar/components/info-dialog.tsx b/apps/web/src/modules/schedule-calendar/components/info-dialog.tsx index f6b12026..746857b0 100644 --- a/apps/web/src/modules/schedule-calendar/components/info-dialog.tsx +++ b/apps/web/src/modules/schedule-calendar/components/info-dialog.tsx @@ -24,6 +24,7 @@ interface CourseInfoDialogProps { id: Id<"userCourseOfferings">, classNumber: number, title: string, + alternativeOf?: Id<"userCourseOfferings">, ) => void; } @@ -41,6 +42,7 @@ export function CourseInfoDialog({ course.userCourseOfferingId as Id<"userCourseOfferings">, course.classNumber, course.title, + course.alternativeOf as Id<"userCourseOfferings"> | undefined, ); onOpenChange(false); } diff --git a/apps/web/src/modules/schedule-calendar/components/week-view.tsx b/apps/web/src/modules/schedule-calendar/components/week-view.tsx index 9e00b131..accdea75 100644 --- a/apps/web/src/modules/schedule-calendar/components/week-view.tsx +++ b/apps/web/src/modules/schedule-calendar/components/week-view.tsx @@ -35,6 +35,8 @@ import { CourseInfoDialog } from "./info-dialog"; interface WeekViewProps { classes: Class[]; hoveredCourseId?: string | null; + selectedCourse?: Class | null; + onCourseSelect?: (course: Class | null) => void; } interface PositionedEvent { @@ -50,12 +52,14 @@ interface PositionedEvent { export function WeekView({ classes, hoveredCourseId: externalHoveredCourseId, + onCourseSelect, }: WeekViewProps) { const currentDate = new Date(); const [internalHoveredCourseId, setInternalHoveredCourseId] = useState< string | null >(null); - const [selectedCourse, setSelectedCourse] = useState(null); + const [internalSelectedCourse, setInternalSelectedCourse] = + useState(null); const [dialogOpen, setDialogOpen] = useState(false); // Combine external hover (from selector) and internal hover (from calendar) @@ -73,13 +77,17 @@ export function WeekView({ id: Id<"userCourseOfferings">, classNumber: number, title: string, + alternativeOf?: Id<"userCourseOfferings">, ) => { try { await removeOffering({ id }); toast.success(`${title} removed`, { action: { label: "Undo", - onClick: () => addOffering({ classNumber }), + onClick: () => + addOffering( + alternativeOf ? { classNumber, alternativeOf } : { classNumber }, + ), }, }); } catch (error) { @@ -92,8 +100,12 @@ export function WeekView({ }; const handleEventClick = (event: Class) => { - setSelectedCourse(event); - setDialogOpen(true); + if (onCourseSelect) { + onCourseSelect(event); + } else { + setInternalSelectedCourse(event); + setDialogOpen(true); + } }; const allDays = useMemo(() => { @@ -332,6 +344,9 @@ export function WeekView({ .userCourseOfferingId as Id<"userCourseOfferings">, positionedEvent.event.classNumber, positionedEvent.event.title, + positionedEvent.event.alternativeOf as + | Id<"userCourseOfferings"> + | undefined, ); }} className="absolute right-1 top-1 z-50 flex size-5 items-center justify-center rounded-full bg-black/10 dark:bg-white/10 text-foreground/70 opacity-0 shadow-md backdrop-blur-sm transition-all hover:bg-black/20 dark:hover:bg-white/20 hover:text-foreground hover:scale-110 group-hover:opacity-100" @@ -351,7 +366,7 @@ export function WeekView({
diff --git a/apps/web/src/modules/schedule-calendar/index.ts b/apps/web/src/modules/schedule-calendar/index.ts new file mode 100644 index 00000000..87ddaf8b --- /dev/null +++ b/apps/web/src/modules/schedule-calendar/index.ts @@ -0,0 +1,2 @@ +export { CourseDetailPanel } from "../course-selection/components/course-detail-panel"; +export * from "./schedule-calendar"; diff --git a/apps/web/src/modules/schedule-calendar/schedule-calendar.tsx b/apps/web/src/modules/schedule-calendar/schedule-calendar.tsx index bf197593..5ed1ffaa 100644 --- a/apps/web/src/modules/schedule-calendar/schedule-calendar.tsx +++ b/apps/web/src/modules/schedule-calendar/schedule-calendar.tsx @@ -29,6 +29,8 @@ export interface Class { times: TimeSlot[]; description: string; isPreview?: boolean; + isAlternative?: boolean; + alternativeOf?: string; section: string; year: number; term: Term; @@ -102,11 +104,15 @@ export interface ScheduleCalendarProps { | FunctionReturnType | undefined; hoveredCourse?: Doc<"courseOfferings"> | null; + selectedCourse?: Class | null; + onCourseSelect?: (course: Class | null) => void; } export function ScheduleCalendar({ classes, hoveredCourse, + selectedCourse, + onCourseSelect, }: ScheduleCalendarProps) { if (!classes) { return ; @@ -181,6 +187,8 @@ export function ScheduleCalendar({ color, times: slots, description: `${offering.instructors.join(", ")} • ${offering.section.toUpperCase()} • ${offering.term} ${offering.year}`, + isAlternative: !!c.alternativeOf, + alternativeOf: c.alternativeOf, section: offering.section, year: offering.year, term: offering.term, @@ -282,6 +290,8 @@ export function ScheduleCalendar({
diff --git a/packages/server/convex/helpers/timeConflicts.ts b/packages/server/convex/helpers/timeConflicts.ts new file mode 100644 index 00000000..87c36dda --- /dev/null +++ b/packages/server/convex/helpers/timeConflicts.ts @@ -0,0 +1,60 @@ +type CourseOffering = { + days: string[]; + startTime: string; // "HH:MM" + endTime: string; // "HH:MM" +}; + +function timeToMinutes(time: string): number { + const [hours, minutes] = time.split(":").map(Number); + return hours * 60 + minutes; +} + +function doTimesOverlap( + _day: string, + start1: string, + end1: string, + start2: string, + end2: string, +): boolean { + const startMin1 = timeToMinutes(start1); + const endMin1 = timeToMinutes(end1); + const startMin2 = timeToMinutes(start2); + const endMin2 = timeToMinutes(end2); + + return startMin1 < endMin2 && startMin2 < endMin1; +} + +/** + * Checks if a new course offering conflicts with existing course offerings + * Returns array of conflicting course class numbers + */ +export function findTimeConflicts( + newCourse: CourseOffering, + existingCourses: Array, +): number[] { + const conflicts: number[] = []; + + for (const existingCourse of existingCourses) { + const commonDays = newCourse.days.filter((day) => + existingCourse.days.includes(day), + ); + + if (commonDays.length > 0) { + const hasOverlap = commonDays.some((day) => + doTimesOverlap( + day, + newCourse.startTime, + newCourse.endTime, + existingCourse.startTime, + existingCourse.endTime, + ), + ); + + if (hasOverlap) { + conflicts.push(existingCourse.classNumber); + } + } + } + + return conflicts; +} diff --git a/packages/server/convex/userCourseOfferings.ts b/packages/server/convex/userCourseOfferings.ts index 34d0e963..47696e7b 100644 --- a/packages/server/convex/userCourseOfferings.ts +++ b/packages/server/convex/userCourseOfferings.ts @@ -2,6 +2,7 @@ import { ConvexError, v } from "convex/values"; import { omit } from "convex-helpers"; import { getOneFrom } from "convex-helpers/server/relationships"; import { protectedMutation, protectedQuery } from "./helpers/auth"; +import { findTimeConflicts } from "./helpers/timeConflicts"; import { userCourseOfferings } from "./schemas/courseOfferings"; export const getUserCourseOfferings = protectedQuery({ @@ -71,8 +72,13 @@ export const getScheduleCourseOfferings = protectedQuery({ }); export const addUserCourseOffering = protectedMutation({ - args: omit(userCourseOfferings, ["userId"]), + args: { + ...omit(userCourseOfferings, ["userId"]), + forceAdd: v.optional(v.boolean()), + }, handler: async (ctx, args) => { + const { forceAdd, ...insertArgs } = args; + const existing = await ctx.db .query("userCourseOfferings") .withIndex("by_user", (q) => q.eq("userId", ctx.user.subject)) @@ -83,10 +89,84 @@ export const addUserCourseOffering = protectedMutation({ throw new ConvexError("Course offering already added to user schedule"); } + const newCourseOffering = await getOneFrom( + ctx.db, + "courseOfferings", + "by_class_number", + args.classNumber, + "classNumber", + ); + + if (!newCourseOffering) { + throw new ConvexError("Course offering not found"); + } + + // Only check for conflicts if this is not being added as an alternative + // and if the new course has time information + if ( + !args.alternativeOf && + !forceAdd && + newCourseOffering.startTime && + newCourseOffering.endTime + ) { + const userCourses = await ctx.db + .query("userCourseOfferings") + .withIndex("by_user", (q) => q.eq("userId", ctx.user.subject)) + .filter((q) => q.eq(q.field("alternativeOf"), undefined)) + .collect(); + + const existingCourseOfferings = await Promise.all( + userCourses.map(async (userCourse) => { + const offering = await getOneFrom( + ctx.db, + "courseOfferings", + "by_class_number", + userCourse.classNumber, + "classNumber", + ); + return offering; + }), + ); + + // Only check courses that have time information + const validOfferings = existingCourseOfferings.filter( + ( + offering, + ): offering is NonNullable & { + startTime: string; + endTime: string; + } => + offering !== null && + offering.startTime !== undefined && + offering.endTime !== undefined, + ); + + const conflicts = findTimeConflicts( + { + days: newCourseOffering.days, + startTime: newCourseOffering.startTime, + endTime: newCourseOffering.endTime, + }, + validOfferings.map((offering) => ({ + days: offering.days, + startTime: offering.startTime, + endTime: offering.endTime, + classNumber: offering.classNumber, + })), + ); + + if (conflicts.length > 0) { + throw new ConvexError({ + type: "TIME_CONFLICT", + conflictingClassNumbers: conflicts, + }); + } + } + return await ctx.db.insert("userCourseOfferings", { userId: ctx.user.subject, - classNumber: args.classNumber, - alternativeOf: args.alternativeOf, + classNumber: insertArgs.classNumber, + alternativeOf: insertArgs.alternativeOf, }); }, }); @@ -118,5 +198,101 @@ export const removeUserCourseOffering = protectedMutation({ } await ctx.db.delete(args.id); + + const alternatives = await ctx.db + .query("userCourseOfferings") + .withIndex("by_user", (q) => q.eq("userId", ctx.user.subject)) + .filter((q) => q.eq(q.field("alternativeOf"), args.id)) + .collect(); + + for (const alternative of alternatives) { + await ctx.db.patch(alternative._id, { alternativeOf: undefined }); + } + }, +}); + +export const swapWithAlternative = protectedMutation({ + args: { + alternativeId: v.id("userCourseOfferings"), + }, + handler: async (ctx, args) => { + const alternative = await ctx.db.get(args.alternativeId); + + if (!alternative || alternative.userId !== ctx.user.subject) { + throw new ConvexError("Alternative course not found or unauthorized"); + } + + if (!alternative.alternativeOf) { + throw new ConvexError( + "This course is not an alternative of another course", + ); + } + + const mainCourse = await ctx.db.get(alternative.alternativeOf); + + if (!mainCourse || mainCourse.userId !== ctx.user.subject) { + throw new ConvexError("Main course not found or unauthorized"); + } + + // swap + await ctx.db.patch(args.alternativeId, { alternativeOf: undefined }); + await ctx.db.patch(alternative.alternativeOf, { + alternativeOf: args.alternativeId, + }); + }, +}); + +export const getAlternativeCourses = protectedQuery({ + args: { userCourseOfferingId: v.id("userCourseOfferings") }, + handler: async (ctx, args) => { + const alternatives = await ctx.db + .query("userCourseOfferings") + .withIndex("by_user", (q) => q.eq("userId", ctx.user.subject)) + .filter((q) => q.eq(q.field("alternativeOf"), args.userCourseOfferingId)) + .collect(); + + return await Promise.all( + alternatives.map(async (alternative) => { + const courseOffering = await getOneFrom( + ctx.db, + "courseOfferings", + "by_class_number", + alternative.classNumber, + "classNumber", + ); + + if (!courseOffering) { + throw new Error("Course offering not found"); + } + + return { + ...alternative, + courseOffering, + }; + }), + ); + }, +}); + +export const getCourseOfferingsByClassNumbers = protectedQuery({ + args: { classNumbers: v.array(v.number()) }, + handler: async (ctx, args) => { + return await Promise.all( + args.classNumbers.map(async (classNumber) => { + const courseOffering = await getOneFrom( + ctx.db, + "courseOfferings", + "by_class_number", + classNumber, + "classNumber", + ); + + if (!courseOffering) { + return null; + } + + return courseOffering; + }), + ); }, });