From 968b661bd44b7d8b318fcdf3139e1ca507445d24 Mon Sep 17 00:00:00 2001 From: Kang Jiaming Date: Sun, 9 Nov 2025 00:25:41 -0500 Subject: [PATCH 01/12] feat: add course detail sidepanel --- .../course-selection/CourseSelector.tsx | 41 +++ .../schedule/components/schedule-calendar.tsx | 6 + .../schedule-calendar/course-detail-panel.tsx | 270 ++++++++++++++++++ .../schedule-calendar/week-view.tsx | 16 +- apps/web/src/app/dashboard/schedule/page.tsx | 50 +++- packages/server/convex/userCourseOfferings.ts | 32 +++ 6 files changed, 409 insertions(+), 6 deletions(-) create mode 100644 apps/web/src/app/dashboard/schedule/components/schedule-calendar/course-detail-panel.tsx diff --git a/apps/web/src/app/dashboard/schedule/components/course-selection/CourseSelector.tsx b/apps/web/src/app/dashboard/schedule/components/course-selection/CourseSelector.tsx index 17cf72e4..1fd9071c 100644 --- a/apps/web/src/app/dashboard/schedule/components/course-selection/CourseSelector.tsx +++ b/apps/web/src/app/dashboard/schedule/components/course-selection/CourseSelector.tsx @@ -1,10 +1,13 @@ "use client"; import { api } from "@albert-plus/server/convex/_generated/api"; +import type { Id } from "@albert-plus/server/convex/_generated/dataModel"; import { useVirtualizer } from "@tanstack/react-virtual"; import { useMutation } from "convex/react"; import { ConvexError } from "convex/values"; import React, { useEffect, useState } from "react"; import { toast } from "sonner"; +import type { Class } from "@/app/dashboard/schedule/components/schedule-calendar"; +import { CourseDetailPanel } from "@/app/dashboard/schedule/components/schedule-calendar/course-detail-panel"; import { Button } from "@/components/ui/button"; import { useSearchParam } from "@/hooks/use-search-param"; import { CourseCard, CourseFilters } from "./components"; @@ -19,6 +22,8 @@ interface CourseSelectorComponentProps { loadMore: (numItems: number) => void; status: "LoadingFirstPage" | "CanLoadMore" | "LoadingMore" | "Exhausted"; isSearching?: boolean; + selectedCourse?: Class | null; + onCourseSelect?: (course: Class | null) => void; } const CourseSelector = ({ @@ -29,6 +34,8 @@ const CourseSelector = ({ loadMore, status, isSearching = false, + selectedCourse, + onCourseSelect, }: CourseSelectorComponentProps) => { const { searchValue: filtersParam, setSearchValue: setFiltersParam } = useSearchParam({ paramKey: "filters", debounceDelay: 0 }); @@ -94,6 +101,40 @@ const CourseSelector = ({ } }; + const handleDelete = async ( + id: Id<"userCourseOfferings">, + classNumber: number, + title: string, + ) => { + try { + await removeCourseOffering({ id }); + toast.success(`${title} removed`, { + action: { + label: "Undo", + onClick: () => addCourseOffering({ classNumber }), + }, + }); + } catch (error) { + const errorMessage = + error instanceof ConvexError + ? (error.data as string) + : "Unexpected error occurred"; + toast.error(errorMessage); + } + }; + + if (selectedCourse) { + return ( +
+ onCourseSelect?.(null)} + onDelete={handleDelete} + /> +
+ ); + } + return (
diff --git a/apps/web/src/app/dashboard/schedule/components/schedule-calendar.tsx b/apps/web/src/app/dashboard/schedule/components/schedule-calendar.tsx index e1522e06..85dfd6de 100644 --- a/apps/web/src/app/dashboard/schedule/components/schedule-calendar.tsx +++ b/apps/web/src/app/dashboard/schedule/components/schedule-calendar.tsx @@ -76,12 +76,16 @@ export interface ScheduleCalendarProps { | undefined; title: string | undefined; hoveredCourse?: Doc<"courseOfferings"> | null; + selectedCourse?: Class | null; + onCourseSelect?: (course: Class | null) => void; } export function ScheduleCalendar({ classes, title, hoveredCourse, + selectedCourse, + onCourseSelect, }: ScheduleCalendarProps) { if (!classes || !title) { return ; @@ -251,6 +255,8 @@ export function ScheduleCalendar({
diff --git a/apps/web/src/app/dashboard/schedule/components/schedule-calendar/course-detail-panel.tsx b/apps/web/src/app/dashboard/schedule/components/schedule-calendar/course-detail-panel.tsx new file mode 100644 index 00000000..5da0602a --- /dev/null +++ b/apps/web/src/app/dashboard/schedule/components/schedule-calendar/course-detail-panel.tsx @@ -0,0 +1,270 @@ +"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"; + +interface CourseDetailPanelProps { + course: Class | null; + onClose: () => void; + onDelete: ( + id: Id<"userCourseOfferings">, + classNumber: number, + title: string, + ) => void; +} + +export function CourseDetailPanel({ + course, + onClose, + onDelete, +}: CourseDetailPanelProps) { + const alternatives = useQuery( + api.userCourseOfferings.getAlternativeCourses, + course?.userCourseOfferingId + ? { + userCourseOfferingId: + course.userCourseOfferingId as Id<"userCourseOfferings">, + } + : "skip", + ); + + if (!course) return null; + + const handleDelete = () => { + if (course.userCourseOfferingId && course.classNumber) { + onDelete( + course.userCourseOfferingId as Id<"userCourseOfferings">, + course.classNumber, + course.title, + ); + 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 +

+
+ + +
+
+

{course.title}

+
+ +
+
+
+

+ Course Code +

+

+ {course.courseCode} +

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

+ Class Number +

+

+ {course.classNumber} +

+
+ )} + +
+

Section

+

+ {course.section.toUpperCase()} +

+
+ +
+

Term

+

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

+
+
+ +
+

+ Instructor +

+

+ {course.instructor.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 +

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

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

+

+ Section {alt.courseOffering.section.toUpperCase()} •{" "} + {alt.courseOffering.instructor.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} +
+
+ ))} +
+
+ + )} +
+
+ + {!course.isPreview && course.userCourseOfferingId && ( +
+ +
+ )} +
+ ); +} diff --git a/apps/web/src/app/dashboard/schedule/components/schedule-calendar/week-view.tsx b/apps/web/src/app/dashboard/schedule/components/schedule-calendar/week-view.tsx index 9e00b131..f126eb66 100644 --- a/apps/web/src/app/dashboard/schedule/components/schedule-calendar/week-view.tsx +++ b/apps/web/src/app/dashboard/schedule/components/schedule-calendar/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) @@ -92,8 +96,12 @@ export function WeekView({ }; const handleEventClick = (event: Class) => { - setSelectedCourse(event); - setDialogOpen(true); + if (onCourseSelect) { + onCourseSelect(event); + } else { + setInternalSelectedCourse(event); + setDialogOpen(true); + } }; const allDays = useMemo(() => { @@ -351,7 +359,7 @@ export function WeekView({ diff --git a/apps/web/src/app/dashboard/schedule/page.tsx b/apps/web/src/app/dashboard/schedule/page.tsx index 67abb68e..0a1d4ead 100644 --- a/apps/web/src/app/dashboard/schedule/page.tsx +++ b/apps/web/src/app/dashboard/schedule/page.tsx @@ -18,7 +18,7 @@ import { } from "@/components/AppConfigProvider"; import { useSearchParam } from "@/hooks/use-search-param"; import { formatTermTitle } from "@/utils/format-term"; -import { ScheduleCalendar } from "./components/schedule-calendar"; +import { type Class, ScheduleCalendar } from "./components/schedule-calendar"; function getUserClassesByTerm( classes: @@ -43,9 +43,47 @@ const SchedulePage = () => { 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); + + 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({ @@ -105,7 +143,7 @@ const SchedulePage = () => {
{/* Mobile toggle buttons */}
- +
{/* Mobile view */} @@ -119,6 +157,8 @@ const SchedulePage = () => { loadMore={loadMore} status={status} isSearching={isSearching} + selectedCourse={selectedCourse} + onCourseSelect={handleCourseSelect} /> ) : (
@@ -126,6 +166,8 @@ const SchedulePage = () => { classes={classes} title={title} hoveredCourse={hoveredCourse} + selectedCourse={selectedCourse} + onCourseSelect={handleCourseSelect} />
)} @@ -141,6 +183,8 @@ const SchedulePage = () => { loadMore={loadMore} status={status} isSearching={isSearching} + selectedCourse={selectedCourse} + onCourseSelect={handleCourseSelect} />
@@ -149,6 +193,8 @@ const SchedulePage = () => { classes={classes} title={title} hoveredCourse={hoveredCourse} + selectedCourse={selectedCourse} + onCourseSelect={handleCourseSelect} />
diff --git a/packages/server/convex/userCourseOfferings.ts b/packages/server/convex/userCourseOfferings.ts index f92c6f62..e6065e68 100644 --- a/packages/server/convex/userCourseOfferings.ts +++ b/packages/server/convex/userCourseOfferings.ts @@ -85,3 +85,35 @@ export const removeUserCourseOffering = protectedMutation({ await ctx.db.delete(args.id); }, }); + +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, + }; + }), + ); + }, +}); From a51cffc039579c0295ef1a3387b614aea438f923 Mon Sep 17 00:00:00 2001 From: Kang Jiaming Date: Sun, 9 Nov 2025 00:43:38 -0500 Subject: [PATCH 02/12] fix imports --- apps/web/src/modules/course-selection/CourseSelector.tsx | 3 +-- apps/web/src/modules/schedule-calendar/index.ts | 2 ++ 2 files changed, 3 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/modules/schedule-calendar/index.ts diff --git a/apps/web/src/modules/course-selection/CourseSelector.tsx b/apps/web/src/modules/course-selection/CourseSelector.tsx index 1fd9071c..367969a0 100644 --- a/apps/web/src/modules/course-selection/CourseSelector.tsx +++ b/apps/web/src/modules/course-selection/CourseSelector.tsx @@ -6,8 +6,7 @@ import { useMutation } from "convex/react"; import { ConvexError } from "convex/values"; import React, { useEffect, useState } from "react"; import { toast } from "sonner"; -import type { Class } from "@/app/dashboard/schedule/components/schedule-calendar"; -import { CourseDetailPanel } from "@/app/dashboard/schedule/components/schedule-calendar/course-detail-panel"; +import { CourseDetailPanel, type Class } from "@/modules/schedule-calendar"; import { Button } from "@/components/ui/button"; import { useSearchParam } from "@/hooks/use-search-param"; import { CourseCard, CourseFilters } from "./components"; 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..d3230e79 --- /dev/null +++ b/apps/web/src/modules/schedule-calendar/index.ts @@ -0,0 +1,2 @@ +export * from "./schedule-calendar"; +export { CourseDetailPanel } from "./components/course-detail-panel"; From 24ded849a95d65b91f8c176c1d6acdfdb0802f80 Mon Sep 17 00:00:00 2001 From: Kang Jiaming Date: Sun, 9 Nov 2025 02:17:07 -0500 Subject: [PATCH 03/12] feat: set as alternative --- apps/web/package.json | 5 +- apps/web/src/app/dashboard/register/page.tsx | 67 ++++-- apps/web/src/components/ui/switch.tsx | 31 +++ .../course-selection/CourseSelector.tsx | 37 +++- .../components/CourseCard.tsx | 7 + .../components/CourseSectionItem.tsx | 201 ++++++++++++++---- .../components/alternative-dropdown.tsx | 64 ++++++ .../course-selection/components/index.ts | 1 + .../components/course-detail-panel.tsx | 24 ++- .../components/event-item.tsx | 9 +- .../src/modules/schedule-calendar/index.ts | 2 +- .../schedule-calendar/schedule-calendar.tsx | 4 + packages/server/convex/userCourseOfferings.ts | 10 + 13 files changed, 395 insertions(+), 67 deletions(-) create mode 100644 apps/web/src/components/ui/switch.tsx create mode 100644 apps/web/src/modules/course-selection/components/alternative-dropdown.tsx diff --git a/apps/web/package.json b/apps/web/package.json index 8fccfab4..5cce5a84 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -12,17 +12,18 @@ "packageManager": "bun@1.2.23", "dependencies": { "@albert-plus/server": "workspace:*", - "@radix-ui/react-checkbox": "^1.3.3", - "@radix-ui/react-select": "^2.2.6", "@clerk/nextjs": "^6.34.5", "@radix-ui/react-avatar": "^1.1.11", + "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-hover-card": "^1.1.15", "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tooltip": "^1.2.8", "@remixicon/react": "^4.7.0", "@t3-oss/env-nextjs": "^0.13.8", diff --git a/apps/web/src/app/dashboard/register/page.tsx b/apps/web/src/app/dashboard/register/page.tsx index 8a02a9e1..77aa34e8 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 { 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 { useSearchParam } from "@/hooks/use-search-param"; import { CourseSelector } from "@/modules/course-selection"; import CourseSelectorSkeleton from "@/modules/course-selection/components/CourseSelectorSkeleton"; @@ -13,9 +15,9 @@ import type { CourseOfferingWithCourse, } from "@/modules/course-selection/types"; import { + type Class, getUserClassesByTerm, ScheduleCalendar, - type Class, } from "@/modules/schedule-calendar/schedule-calendar"; const RegisterPage = () => { @@ -35,6 +37,9 @@ const RegisterPage = () => { >("selector"); const [isMobile, setIsMobile] = useState(false); + // TODO: save the state to cookie + const [showAlternatives, setShowAlternatives] = useState(true); + useEffect(() => { const checkMobile = () => { setIsMobile(window.innerWidth < 768); @@ -104,7 +109,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" && @@ -120,6 +134,23 @@ const RegisterPage = () => { return ; } + const AltToggle = () => ( + <> + +
+ +

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

+
+ + ); + return (
{/* Mobile toggle buttons */} @@ -142,7 +173,10 @@ const RegisterPage = () => { onCourseSelect={handleCourseSelect} /> ) : ( -
+
+
+ +
{ {/* Desktop view */}
- +
+
+ +
+ +
diff --git a/apps/web/src/components/ui/switch.tsx b/apps/web/src/components/ui/switch.tsx new file mode 100644 index 00000000..6a2b5241 --- /dev/null +++ b/apps/web/src/components/ui/switch.tsx @@ -0,0 +1,31 @@ +"use client" + +import * as React from "react" +import * as SwitchPrimitive from "@radix-ui/react-switch" + +import { cn } from "@/lib/utils" + +function Switch({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +export { Switch } diff --git a/apps/web/src/modules/course-selection/CourseSelector.tsx b/apps/web/src/modules/course-selection/CourseSelector.tsx index 367969a0..61868292 100644 --- a/apps/web/src/modules/course-selection/CourseSelector.tsx +++ b/apps/web/src/modules/course-selection/CourseSelector.tsx @@ -6,9 +6,9 @@ import { useMutation } from "convex/react"; import { ConvexError } from "convex/values"; import React, { useEffect, useState } from "react"; import { toast } from "sonner"; -import { CourseDetailPanel, type Class } from "@/modules/schedule-calendar"; import { Button } from "@/components/ui/button"; import { useSearchParam } from "@/hooks/use-search-param"; +import { type Class, CourseDetailPanel } from "@/modules/schedule-calendar"; import { CourseCard, CourseFilters } from "./components"; import { useCourseExpansion, useCourseFiltering } from "./hooks"; import type { CourseOffering, CourseOfferingWithCourse } from "./types"; @@ -100,6 +100,38 @@ const CourseSelector = ({ } }; + 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, + 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, @@ -204,6 +236,9 @@ const CourseSelector = ({ isExpanded={isExpanded(course.code)} onToggleExpand={toggleCourseExpansion} onSectionSelect={handleSectionSelect} + onSectionSelectAsAlternative={ + handleSectionSelectAsAlternative + } onSectionHover={setHoveredSection} />
diff --git a/apps/web/src/modules/course-selection/components/CourseCard.tsx b/apps/web/src/modules/course-selection/components/CourseCard.tsx index 99043be0..a1aa46bd 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 { @@ -20,6 +21,10 @@ interface CourseCardProps { isExpanded: boolean; onToggleExpand: (courseCode: string) => void; onSectionSelect?: (offering: CourseOffering) => void; + onSectionSelectAsAlternative?: ( + offering: CourseOffering, + alternativeOf: Id<"userCourseOfferings">, + ) => void; onSectionHover?: (offering: CourseOffering | null) => void; } @@ -28,6 +33,7 @@ export const CourseCard = ({ isExpanded, onToggleExpand, onSectionSelect, + onSectionSelectAsAlternative, onSectionHover, }: CourseCardProps) => { return ( @@ -88,6 +94,7 @@ export const CourseCard = ({ key={offering._id} offering={offering} 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 8b9bf894..7953a772 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/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..ed39ac96 --- /dev/null +++ b/apps/web/src/modules/course-selection/components/alternative-dropdown.tsx @@ -0,0 +1,64 @@ +"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 { 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 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) => ( + 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.instructor.join(", ")} + + + ))} + + + ); +} diff --git a/apps/web/src/modules/course-selection/components/index.ts b/apps/web/src/modules/course-selection/components/index.ts index 06effb2b..7d66c2c4 100644 --- a/apps/web/src/modules/course-selection/components/index.ts +++ b/apps/web/src/modules/course-selection/components/index.ts @@ -1,3 +1,4 @@ +export { AlternativeDropdown } from "./alternative-dropdown"; export { CourseCard } from "./CourseCard"; export { CourseFilters } from "./CourseFilters"; export { CourseSectionItem } from "./CourseSectionItem"; diff --git a/apps/web/src/modules/schedule-calendar/components/course-detail-panel.tsx b/apps/web/src/modules/schedule-calendar/components/course-detail-panel.tsx index 5da0602a..4561e82d 100644 --- a/apps/web/src/modules/schedule-calendar/components/course-detail-panel.tsx +++ b/apps/web/src/modules/schedule-calendar/components/course-detail-panel.tsx @@ -88,15 +88,19 @@ export function CourseDetailPanel({ > -

- Course Details -

+

Course Details

-

{course.title}

+

+ {/* + 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} +

@@ -140,7 +144,7 @@ export function CourseDetailPanel({

Instructor

-

+

{course.instructor.join(", ") || "TBA"}

@@ -150,7 +154,7 @@ export function CourseDetailPanel({

Location

-

+

{course.location}

@@ -162,7 +166,7 @@ export function CourseDetailPanel({ {course.times.map((slot, index) => (

{formatTimeSlot(slot)}

@@ -218,11 +222,11 @@ export function CourseDetailPanel({ >
-

+

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

-

+

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

@@ -234,7 +238,7 @@ export function CourseDetailPanel({ alt.courseOffering.status.slice(1)}
-
+
{alt.courseOffering.days .map( (day) => day.charAt(0).toUpperCase() + day.slice(1), 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/index.ts b/apps/web/src/modules/schedule-calendar/index.ts index d3230e79..94986dce 100644 --- a/apps/web/src/modules/schedule-calendar/index.ts +++ b/apps/web/src/modules/schedule-calendar/index.ts @@ -1,2 +1,2 @@ -export * from "./schedule-calendar"; export { CourseDetailPanel } from "./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 84b801be..dce8c029 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; @@ -170,6 +172,8 @@ export function ScheduleCalendar({ color, times: slots, description: `${offering.instructor.join(", ")} • ${offering.section.toUpperCase()} • ${offering.term} ${offering.year}`, + isAlternative: !!c.alternativeOf, + alternativeOf: c.alternativeOf, section: offering.section, year: offering.year, term: offering.term, diff --git a/packages/server/convex/userCourseOfferings.ts b/packages/server/convex/userCourseOfferings.ts index a51292e1..b6a6e60e 100644 --- a/packages/server/convex/userCourseOfferings.ts +++ b/packages/server/convex/userCourseOfferings.ts @@ -118,6 +118,16 @@ 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.delete(alternative._id); + } }, }); From 2f3d1dfa061c39f01a32921cb9c96cbfe455f74f Mon Sep 17 00:00:00 2001 From: Kang Jiaming Date: Sun, 9 Nov 2025 16:18:05 -0500 Subject: [PATCH 04/12] fix: dark mode --- .../modules/course-selection/components/CourseSectionItem.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/modules/course-selection/components/CourseSectionItem.tsx b/apps/web/src/modules/course-selection/components/CourseSectionItem.tsx index 7953a772..b90cd81b 100644 --- a/apps/web/src/modules/course-selection/components/CourseSectionItem.tsx +++ b/apps/web/src/modules/course-selection/components/CourseSectionItem.tsx @@ -129,7 +129,7 @@ export const CourseSectionItem = ({
{showSelector && ( <> -
+
); }; 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..47c185fc --- /dev/null +++ b/apps/web/src/modules/course-selection/components/conflict-dialog.tsx @@ -0,0 +1,228 @@ +"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 { useEffect, useState } from "react"; +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 [dropdownOpen, setDropdownOpen] = useState(false); + + const userCourses = useQuery(api.userCourseOfferings.getUserCourseOfferings); + + useEffect(() => { + if (!open) { + setDropdownOpen(false); + } + }, [open]); + + const conflictingMainCourses = userCourses?.filter( + (course) => + !course.alternativeOf && + 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.instructor.join(", ")} + + + ))} + + + +
+ + + +
+
+
+ ); +} diff --git a/apps/web/src/modules/schedule-calendar/components/course-detail-panel.tsx b/apps/web/src/modules/course-selection/components/course-detail-panel.tsx similarity index 99% rename from apps/web/src/modules/schedule-calendar/components/course-detail-panel.tsx rename to apps/web/src/modules/course-selection/components/course-detail-panel.tsx index 4561e82d..93dc0930 100644 --- a/apps/web/src/modules/schedule-calendar/components/course-detail-panel.tsx +++ b/apps/web/src/modules/course-selection/components/course-detail-panel.tsx @@ -8,7 +8,7 @@ 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"; +import type { Class } from "../../schedule-calendar/schedule-calendar"; interface CourseDetailPanelProps { course: Class | null; diff --git a/apps/web/src/modules/course-selection/components/index.ts b/apps/web/src/modules/course-selection/components/index.ts index 7d66c2c4..5741b657 100644 --- a/apps/web/src/modules/course-selection/components/index.ts +++ b/apps/web/src/modules/course-selection/components/index.ts @@ -1,4 +1,5 @@ export { AlternativeDropdown } from "./alternative-dropdown"; +export { default as ConflictDialog } from "./conflict-dialog"; export { CourseCard } from "./CourseCard"; export { CourseFilters } from "./CourseFilters"; export { CourseSectionItem } from "./CourseSectionItem"; diff --git a/apps/web/src/modules/schedule-calendar/index.ts b/apps/web/src/modules/schedule-calendar/index.ts index 94986dce..87ddaf8b 100644 --- a/apps/web/src/modules/schedule-calendar/index.ts +++ b/apps/web/src/modules/schedule-calendar/index.ts @@ -1,2 +1,2 @@ -export { CourseDetailPanel } from "./components/course-detail-panel"; +export { CourseDetailPanel } from "../course-selection/components/course-detail-panel"; export * from "./schedule-calendar"; diff --git a/packages/server/convex/helpers/timeConflicts.ts b/packages/server/convex/helpers/timeConflicts.ts new file mode 100644 index 00000000..4d59798b --- /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 b6a6e60e..4a762d3b 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,69 @@ 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 + if (!args.alternativeOf && !forceAdd) { + 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; + }), + ); + + const validOfferings = existingCourseOfferings.filter( + (offering) => offering !== null, + ); + + 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, }); }, }); @@ -162,3 +227,26 @@ export const getAlternativeCourses = protectedQuery({ ); }, }); + +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; + }), + ); + }, +}); From e9bdad653e1e84c16c9823f209e4a01a6bd42c2d Mon Sep 17 00:00:00 2001 From: Kang Jiaming Date: Mon, 17 Nov 2025 20:24:00 -0500 Subject: [PATCH 06/12] fix: undo action on delete alternative offering will fail --- apps/web/src/modules/course-selection/CourseSelector.tsx | 6 +++++- .../course-selection/components/conflict-dialog.tsx | 6 ------ .../course-selection/components/course-detail-panel.tsx | 2 ++ .../web/src/modules/course-selection/components/index.ts | 2 +- .../modules/schedule-calendar/components/info-dialog.tsx | 2 ++ .../modules/schedule-calendar/components/week-view.tsx | 9 ++++++++- 6 files changed, 18 insertions(+), 9 deletions(-) diff --git a/apps/web/src/modules/course-selection/CourseSelector.tsx b/apps/web/src/modules/course-selection/CourseSelector.tsx index ae33ec17..9c556339 100644 --- a/apps/web/src/modules/course-selection/CourseSelector.tsx +++ b/apps/web/src/modules/course-selection/CourseSelector.tsx @@ -167,13 +167,17 @@ const CourseSelector = ({ id: Id<"userCourseOfferings">, classNumber: number, title: string, + alternativeOf?: Id<"userCourseOfferings">, ) => { try { await removeCourseOffering({ id }); toast.success(`${title} removed`, { action: { label: "Undo", - onClick: () => addCourseOffering({ classNumber }), + onClick: () => + addCourseOffering( + alternativeOf ? { classNumber, alternativeOf } : { classNumber }, + ), }, }); } catch (error) { diff --git a/apps/web/src/modules/course-selection/components/conflict-dialog.tsx b/apps/web/src/modules/course-selection/components/conflict-dialog.tsx index 47c185fc..83d38707 100644 --- a/apps/web/src/modules/course-selection/components/conflict-dialog.tsx +++ b/apps/web/src/modules/course-selection/components/conflict-dialog.tsx @@ -49,12 +49,6 @@ export default function ConflictDialog({ const userCourses = useQuery(api.userCourseOfferings.getUserCourseOfferings); - useEffect(() => { - if (!open) { - setDropdownOpen(false); - } - }, [open]); - const conflictingMainCourses = userCourses?.filter( (course) => !course.alternativeOf && 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 index 93dc0930..3c4c6f43 100644 --- a/apps/web/src/modules/course-selection/components/course-detail-panel.tsx +++ b/apps/web/src/modules/course-selection/components/course-detail-panel.tsx @@ -17,6 +17,7 @@ interface CourseDetailPanelProps { id: Id<"userCourseOfferings">, classNumber: number, title: string, + alternativeOf?: Id<"userCourseOfferings">, ) => void; } @@ -43,6 +44,7 @@ export function CourseDetailPanel({ course.userCourseOfferingId as Id<"userCourseOfferings">, course.classNumber, course.title, + course.alternativeOf as Id<"userCourseOfferings"> | undefined, ); onClose(); } diff --git a/apps/web/src/modules/course-selection/components/index.ts b/apps/web/src/modules/course-selection/components/index.ts index 5741b657..8e3f585c 100644 --- a/apps/web/src/modules/course-selection/components/index.ts +++ b/apps/web/src/modules/course-selection/components/index.ts @@ -1,7 +1,7 @@ export { AlternativeDropdown } from "./alternative-dropdown"; -export { default as ConflictDialog } from "./conflict-dialog"; 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/info-dialog.tsx b/apps/web/src/modules/schedule-calendar/components/info-dialog.tsx index 97be1c8d..4c5f9d20 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 f126eb66..accdea75 100644 --- a/apps/web/src/modules/schedule-calendar/components/week-view.tsx +++ b/apps/web/src/modules/schedule-calendar/components/week-view.tsx @@ -77,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) { @@ -340,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" From b0f93c2d046291395402ea825362c443e95a9262 Mon Sep 17 00:00:00 2001 From: Kang Jiaming Date: Mon, 24 Nov 2025 10:05:50 -0500 Subject: [PATCH 07/12] fix: error alternative display when no main course added; infinite re-fetch --- apps/web/src/hooks/use-search-param.ts | 23 ++++++++++++------- .../components/CourseSectionItem.tsx | 17 +++++++------- .../components/conflict-dialog.tsx | 2 +- .../schedule-calendar/schedule-calendar.tsx | 4 ++-- .../server/convex/helpers/timeConflicts.ts | 2 +- 5 files changed, 27 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..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,16 +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 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-selection/components/CourseSectionItem.tsx b/apps/web/src/modules/course-selection/components/CourseSectionItem.tsx index b90cd81b..5dad23c0 100644 --- a/apps/web/src/modules/course-selection/components/CourseSectionItem.tsx +++ b/apps/web/src/modules/course-selection/components/CourseSectionItem.tsx @@ -62,14 +62,6 @@ export const CourseSectionItem = ({ // Filter out courses that are alternatives themselves const mainCourses = userCourses?.filter((course) => !course.alternativeOf); - if (!mainCourses || mainCourses.length === 0) { - return ( - - ); - } - return ( <> {/** biome-ignore lint/a11y/useSemanticElements: button inside button will cause hydration error */} @@ -156,7 +148,14 @@ export const CourseSectionItem = ({ - {mainCourses.map((course) => ( + {mainCourses?.length === 0 && ( + + + Please add a course first. + + + )} + {mainCourses?.map((course) => ( handleAddAsAlternative(course._id)} diff --git a/apps/web/src/modules/course-selection/components/conflict-dialog.tsx b/apps/web/src/modules/course-selection/components/conflict-dialog.tsx index 83d38707..83f4084b 100644 --- a/apps/web/src/modules/course-selection/components/conflict-dialog.tsx +++ b/apps/web/src/modules/course-selection/components/conflict-dialog.tsx @@ -4,7 +4,7 @@ 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 { useEffect, useState } from "react"; +import { useState } from "react"; import { Button } from "@/components/ui/button"; import { Dialog, diff --git a/apps/web/src/modules/schedule-calendar/schedule-calendar.tsx b/apps/web/src/modules/schedule-calendar/schedule-calendar.tsx index f7c91da4..34c730f9 100644 --- a/apps/web/src/modules/schedule-calendar/schedule-calendar.tsx +++ b/apps/web/src/modules/schedule-calendar/schedule-calendar.tsx @@ -179,7 +179,7 @@ export function ScheduleCalendar({ title: `${offering.courseCode} - ${offering.title}`, color, times: slots, - description: `${offering.instructor.join(", ")} • ${offering.section.toUpperCase()} • ${offering.term} ${offering.year}`, + description: `${offering.instructor?.join(", ")} • ${offering.section.toUpperCase()} • ${offering.term} ${offering.year}`, isAlternative: !!c.alternativeOf, alternativeOf: c.alternativeOf, section: offering.section, @@ -249,7 +249,7 @@ export function ScheduleCalendar({ title: `${offering.courseCode} - ${offering.title}`, color: getColor(offering._id), times: slots, - description: `${offering.instructor.join(", ")} • ${offering.section.toUpperCase()} • Preview`, + description: `${offering.instructor?.join(", ")} • ${offering.section.toUpperCase()} • Preview`, isPreview: true, section: offering.section, year: offering.year, diff --git a/packages/server/convex/helpers/timeConflicts.ts b/packages/server/convex/helpers/timeConflicts.ts index 4d59798b..87c36dda 100644 --- a/packages/server/convex/helpers/timeConflicts.ts +++ b/packages/server/convex/helpers/timeConflicts.ts @@ -10,7 +10,7 @@ function timeToMinutes(time: string): number { } function doTimesOverlap( - day: string, + _day: string, start1: string, end1: string, start2: string, From 80b66cf6d7b37750939e74eadd9e4158a43dd3db Mon Sep 17 00:00:00 2001 From: Kang Jiaming Date: Mon, 24 Nov 2025 10:36:11 -0500 Subject: [PATCH 08/12] fix: conflict detection not working; rename instructor to instructors --- .../components/CourseSectionItem.tsx | 3 ++- .../components/alternative-dropdown.tsx | 2 +- .../components/conflict-dialog.tsx | 2 +- .../components/course-detail-panel.tsx | 2 +- packages/server/convex/userCourseOfferings.ts | 14 ++++++++++++-- 5 files changed, 17 insertions(+), 6 deletions(-) diff --git a/apps/web/src/modules/course-selection/components/CourseSectionItem.tsx b/apps/web/src/modules/course-selection/components/CourseSectionItem.tsx index e8c55837..c9bfafd3 100644 --- a/apps/web/src/modules/course-selection/components/CourseSectionItem.tsx +++ b/apps/web/src/modules/course-selection/components/CourseSectionItem.tsx @@ -111,7 +111,8 @@ export const CourseSectionItem = ({ "text-xs px-2 py-1 rounded-full font-medium capitalize", offering.status === "open" && "bg-green-100 text-green-800", offering.status === "closed" && "bg-red-100 text-red-800", - offering.status === "waitlist" && "bg-yellow-100 text-yellow-800", + offering.status === "waitlist" && + "bg-yellow-100 text-yellow-800", )} > {offering.status === "waitlist" diff --git a/apps/web/src/modules/course-selection/components/alternative-dropdown.tsx b/apps/web/src/modules/course-selection/components/alternative-dropdown.tsx index ed39ac96..aae12d72 100644 --- a/apps/web/src/modules/course-selection/components/alternative-dropdown.tsx +++ b/apps/web/src/modules/course-selection/components/alternative-dropdown.tsx @@ -54,7 +54,7 @@ export function AlternativeDropdown({ onSelect }: AlternativeDropdownProps) { Section {course.courseOffering.section.toUpperCase()} •{" "} - {course.courseOffering.instructor.join(", ")} + {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 index 83f4084b..0eafe1ad 100644 --- a/apps/web/src/modules/course-selection/components/conflict-dialog.tsx +++ b/apps/web/src/modules/course-selection/components/conflict-dialog.tsx @@ -187,7 +187,7 @@ export default function ConflictDialog({ Section {course.courseOffering.section.toUpperCase()} •{" "} - {course.courseOffering.instructor.join(", ")} + {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 index 3c4c6f43..01374bec 100644 --- a/apps/web/src/modules/course-selection/components/course-detail-panel.tsx +++ b/apps/web/src/modules/course-selection/components/course-detail-panel.tsx @@ -147,7 +147,7 @@ export function CourseDetailPanel({ Instructor

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

diff --git a/packages/server/convex/userCourseOfferings.ts b/packages/server/convex/userCourseOfferings.ts index 4a762d3b..2e956be6 100644 --- a/packages/server/convex/userCourseOfferings.ts +++ b/packages/server/convex/userCourseOfferings.ts @@ -102,7 +102,13 @@ export const addUserCourseOffering = protectedMutation({ } // Only check for conflicts if this is not being added as an alternative - if (!args.alternativeOf && !forceAdd) { + // 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)) @@ -122,8 +128,12 @@ export const addUserCourseOffering = protectedMutation({ }), ); + // Only check courses that have time information const validOfferings = existingCourseOfferings.filter( - (offering) => offering !== null, + (offering): offering is NonNullable & { startTime: string; endTime: string } => + offering !== null && + offering.startTime !== undefined && + offering.endTime !== undefined, ); const conflicts = findTimeConflicts( From afbdf8f43aeef91f85ddd31f4448a2615d7a1d65 Mon Sep 17 00:00:00 2001 From: Kang Jiaming Date: Mon, 24 Nov 2025 15:51:16 -0500 Subject: [PATCH 09/12] fix: alternative scope to only selected year --- .../components/CourseSectionItem.tsx | 14 +++++++++++--- .../components/alternative-dropdown.tsx | 12 ++++++++++-- .../components/conflict-dialog.tsx | 5 +++++ .../components/course-detail-panel.tsx | 2 +- packages/server/convex/userCourseOfferings.ts | 7 ++++++- 5 files changed, 33 insertions(+), 7 deletions(-) diff --git a/apps/web/src/modules/course-selection/components/CourseSectionItem.tsx b/apps/web/src/modules/course-selection/components/CourseSectionItem.tsx index c9bfafd3..12a3c2c4 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,6 +34,9 @@ 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] = @@ -61,8 +64,13 @@ export const CourseSectionItem = ({ const userCourses = useQuery(api.userCourseOfferings.getUserCourseOfferings); - // Filter out courses that are alternatives themselves - const mainCourses = userCourses?.filter((course) => !course.alternativeOf); + // 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, + ); const isSelected = selectedClassNumbers?.includes(offering.classNumber); diff --git a/apps/web/src/modules/course-selection/components/alternative-dropdown.tsx b/apps/web/src/modules/course-selection/components/alternative-dropdown.tsx index aae12d72..5544474c 100644 --- a/apps/web/src/modules/course-selection/components/alternative-dropdown.tsx +++ b/apps/web/src/modules/course-selection/components/alternative-dropdown.tsx @@ -4,6 +4,7 @@ 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, @@ -17,10 +18,17 @@ interface AlternativeDropdownProps { } export function AlternativeDropdown({ onSelect }: AlternativeDropdownProps) { + const term = useNextTerm(); + const year = useNextYear(); const userCourses = useQuery(api.userCourseOfferings.getUserCourseOfferings); - // Filter out courses that are alternatives themselves - const mainCourses = userCourses?.filter((course) => !course.alternativeOf); + // 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 ( diff --git a/apps/web/src/modules/course-selection/components/conflict-dialog.tsx b/apps/web/src/modules/course-selection/components/conflict-dialog.tsx index 0eafe1ad..edfbfb7d 100644 --- a/apps/web/src/modules/course-selection/components/conflict-dialog.tsx +++ b/apps/web/src/modules/course-selection/components/conflict-dialog.tsx @@ -5,6 +5,7 @@ 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, @@ -45,6 +46,8 @@ export default function ConflictDialog({ onCancel, isAdding = false, }: ConflictDialogProps) { + const term = useNextTerm(); + const year = useNextYear(); const [dropdownOpen, setDropdownOpen] = useState(false); const userCourses = useQuery(api.userCourseOfferings.getUserCourseOfferings); @@ -52,6 +55,8 @@ export default function ConflictDialog({ const conflictingMainCourses = userCourses?.filter( (course) => !course.alternativeOf && + course.courseOffering.term === term && + course.courseOffering.year === year && conflictingCourses.some( (c) => c.classNumber === course.courseOffering.classNumber, ), 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 index 01374bec..9af168d7 100644 --- a/apps/web/src/modules/course-selection/components/course-detail-panel.tsx +++ b/apps/web/src/modules/course-selection/components/course-detail-panel.tsx @@ -230,7 +230,7 @@ export function CourseDetailPanel({

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

& { startTime: string; endTime: string } => + ( + offering, + ): offering is NonNullable & { + startTime: string; + endTime: string; + } => offering !== null && offering.startTime !== undefined && offering.endTime !== undefined, From 723f37e3e9bfa8b80ebd25f9c9072ec450dae1a2 Mon Sep 17 00:00:00 2001 From: Kang Jiaming Date: Mon, 24 Nov 2025 16:26:44 -0500 Subject: [PATCH 10/12] fix: If course A is an alternative of B, When B is deleted, A should be available. --- packages/server/convex/userCourseOfferings.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/server/convex/userCourseOfferings.ts b/packages/server/convex/userCourseOfferings.ts index 86dff220..181fb4a2 100644 --- a/packages/server/convex/userCourseOfferings.ts +++ b/packages/server/convex/userCourseOfferings.ts @@ -206,7 +206,7 @@ export const removeUserCourseOffering = protectedMutation({ .collect(); for (const alternative of alternatives) { - await ctx.db.delete(alternative._id); + await ctx.db.patch(alternative._id, { alternativeOf: undefined }); } }, }); From 65339890cdb1af9b29804ac413d984db1033a7f4 Mon Sep 17 00:00:00 2001 From: Kang Jiaming Date: Mon, 1 Dec 2025 10:08:56 -0500 Subject: [PATCH 11/12] feat: switch between alternative and main courses --- .../course-selection/CourseSelector.tsx | 17 ++++++ .../components/course-detail-panel.tsx | 61 +++++++++++++++++-- packages/server/convex/userCourseOfferings.ts | 31 ++++++++++ 3 files changed, 105 insertions(+), 4 deletions(-) diff --git a/apps/web/src/modules/course-selection/CourseSelector.tsx b/apps/web/src/modules/course-selection/CourseSelector.tsx index c57e78ad..02be5821 100644 --- a/apps/web/src/modules/course-selection/CourseSelector.tsx +++ b/apps/web/src/modules/course-selection/CourseSelector.tsx @@ -55,6 +55,10 @@ const CourseSelector = ({ api.userCourseOfferings.removeUserCourseOffering, ); + const swapWithAlternative = useMutation( + api.userCourseOfferings.swapWithAlternative, + ); + const [hoveredSection, setHoveredSection] = useState( null, ); @@ -213,6 +217,18 @@ const CourseSelector = ({ } }; + 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; @@ -286,6 +302,7 @@ const CourseSelector = ({ course={selectedCourse} onClose={() => onCourseSelect?.(null)} onDelete={handleDelete} + onSwap={handleSwap} />
); 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 index 9af168d7..43f07779 100644 --- a/apps/web/src/modules/course-selection/components/course-detail-panel.tsx +++ b/apps/web/src/modules/course-selection/components/course-detail-panel.tsx @@ -19,12 +19,14 @@ interface CourseDetailPanelProps { 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, @@ -36,6 +38,24 @@ export function CourseDetailPanel({ : "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 = () => { @@ -44,7 +64,7 @@ export function CourseDetailPanel({ course.userCourseOfferingId as Id<"userCourseOfferings">, course.classNumber, course.title, - course.alternativeOf as Id<"userCourseOfferings"> | undefined, + alternativeOfId ?? undefined, ); onClose(); } @@ -214,17 +234,17 @@ export function CourseDetailPanel({

- Alternative Courses + Alternative Courses Added by You

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

+

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

@@ -249,12 +269,45 @@ export function CourseDetailPanel({ • {alt.courseOffering.startTime} -{" "} {alt.courseOffering.endTime}
+
))}
)} + + {/* Alternative Course Info */} + {mainCourse && ( +
+

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

+ +
+ )}
diff --git a/packages/server/convex/userCourseOfferings.ts b/packages/server/convex/userCourseOfferings.ts index 181fb4a2..47696e7b 100644 --- a/packages/server/convex/userCourseOfferings.ts +++ b/packages/server/convex/userCourseOfferings.ts @@ -211,6 +211,37 @@ export const removeUserCourseOffering = protectedMutation({ }, }); +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) => { From 44a7770a03d7ffb679282b6bce93c85d2da3c299 Mon Sep 17 00:00:00 2001 From: Kang Jiaming Date: Wed, 10 Dec 2025 18:31:49 -0500 Subject: [PATCH 12/12] fix: remove virtualizer --- .../course-selection/CourseSelector.tsx | 94 +++++++------------ 1 file changed, 32 insertions(+), 62 deletions(-) diff --git a/apps/web/src/modules/course-selection/CourseSelector.tsx b/apps/web/src/modules/course-selection/CourseSelector.tsx index 02be5821..da719926 100644 --- a/apps/web/src/modules/course-selection/CourseSelector.tsx +++ b/apps/web/src/modules/course-selection/CourseSelector.tsx @@ -1,10 +1,9 @@ "use client"; import { api } from "@albert-plus/server/convex/_generated/api"; import type { Id } from "@albert-plus/server/convex/_generated/dataModel"; -import { useVirtualizer } from "@tanstack/react-virtual"; 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"; @@ -91,33 +90,28 @@ 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(() => { - 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(); + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting && status === "CanLoadMore") { + loadMore(200); + } + }, + { threshold: 0.1 }, + ); - if (!lastItem) { - return; + if (observerTarget.current) { + observer.observe(observerTarget.current); } - if (lastItem.index >= filteredData.length - 1 && status === "CanLoadMore") { - loadMore(200); - } - }, [status, loadMore, filteredData.length, rowVirtualizer.getVirtualItems()]); + return () => observer.disconnect(); + }, [status, loadMore]); + + useEffect(() => { + onHover?.(hoveredSection); + }, [hoveredSection, onHover]); const handleSectionSelect = async (offering: CourseOffering) => { if (offering.status === "closed") { @@ -350,44 +344,20 @@ const CourseSelector = ({ )} {filteredData.length > 0 && ( -
-
- {rowVirtualizer.getVirtualItems().map((virtualItem) => { - const course = filteredData[virtualItem.index]; - - return ( -
- -
- ); - })} -
+
+ {filteredData.map((course) => ( + + ))} +
)}