import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import { ELECTIVE_SETS } from '../data/electiveSets'; import { SPECIALIZATIONS } from '../data/specializations'; import { coursesBySet } from '../data/lookups'; import { COURSE_DESCRIPTIONS } from '../data/courseDescriptions'; import { courseById } from '../data/lookups'; import { useMediaQuery } from '../hooks/useMediaQuery'; import { makePriorityScorer, makePriorityRankWeight } from '../solver/priority'; import { specColor } from '../data/specColors'; import type { OptimizationMode, Term } from '../data/types'; import type { SetAnalysis } from '../solver/decisionTree'; function SpecTag({ specId }: { specId: string }) { const c = specColor(specId); return ( {specId} ); } // Reverse map: courseId → specialization names that require it const requiredForSpec: Record = {}; for (const spec of SPECIALIZATIONS) { if (spec.requiredCourseId) { (requiredForSpec[spec.requiredCourseId] ??= []).push(spec.name); } } // specId → full spec name const specNameById: Record = {}; for (const spec of SPECIALIZATIONS) { specNameById[spec.id] = spec.name; } function CourseInfoPopover({ courseId, courseName, anchorRect, onClose, onHoverEnter, onHoverLeave, }: { courseId: string; courseName: string; anchorRect: DOMRect | null; onClose: () => void; onHoverEnter: () => void; onHoverLeave: () => void; }) { const popoverRef = useRef(null); const breakpoint = useMediaQuery(); const isMobile = breakpoint === 'mobile'; const info = COURSE_DESCRIPTIONS[courseId]; // Close on Escape useEffect(() => { function onKeyDown(e: KeyboardEvent) { if (e.key === 'Escape') onClose(); } document.addEventListener('keydown', onKeyDown); return () => document.removeEventListener('keydown', onKeyDown); }, [onClose]); // Close on click outside useEffect(() => { function onClickOutside(e: MouseEvent) { if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) { onClose(); } } // Defer so the opening click doesn't immediately close const id = setTimeout(() => document.addEventListener('mousedown', onClickOutside), 0); return () => { clearTimeout(id); document.removeEventListener('mousedown', onClickOutside); }; }, [onClose]); if (!info) return null; const instructorLabel = info.instructors.length > 1 ? 'Instructors' : 'Instructor'; const instructorText = info.instructors.length > 0 ? info.instructors.join(', ') : null; // Mobile: centered fixed overlay. Desktop: anchored near the icon, flipping above if needed. const popoverMaxHeight = 300; const spaceBelow = anchorRect ? window.innerHeight - anchorRect.bottom - 6 : popoverMaxHeight; const spaceAbove = anchorRect ? anchorRect.top - 6 : popoverMaxHeight; const placeAbove = spaceBelow < Math.min(popoverMaxHeight, 150) && spaceAbove > spaceBelow; const positionStyle: React.CSSProperties = isMobile ? { position: 'fixed', left: '16px', right: '16px', top: '50%', transform: 'translateY(-50%)', maxWidth: 'calc(100vw - 32px)', } : { position: 'fixed', left: anchorRect ? Math.min(anchorRect.left, window.innerWidth - 340) : 0, ...(placeAbove ? { bottom: anchorRect ? window.innerHeight - anchorRect.top + 6 : 0, maxHeight: Math.min(popoverMaxHeight, spaceAbove) } : { top: anchorRect ? anchorRect.bottom + 6 : 0, maxHeight: Math.min(popoverMaxHeight, spaceBelow) }), maxWidth: '320px', }; return ( <> {isMobile && (
)}
{courseName}
{instructorText && (
{instructorLabel}: {instructorText}
)} {courseById[courseId]?.qualifications.length > 0 && (
{courseById[courseId].qualifications.map((q) => ( {q.specId}{q.marker !== 'standard' ? ` (${q.marker})` : ''} ))}
)}
{info.description}
); } interface CourseSelectionProps { pinnedCourses: Record; treeResults: SetAnalysis[]; treeLoading: boolean; disabledCourseIds: Set; ranking: string[]; mode: OptimizationMode; onPin: (setId: string, courseId: string) => void; onUnpin: (setId: string) => void; onClearAll: () => void; } function ElectiveSet({ setId, setName, pinnedCourseId, analysis, loading, disabledCourseIds, scorer, rankWeight, mode, onPin, onUnpin, openPopoverId, onOpenPopover, onClosePopover, onHoverOpen, onHoverLeave, }: { setId: string; setName: string; pinnedCourseId: string | null | undefined; analysis?: SetAnalysis; loading: boolean; disabledCourseIds: Set; scorer: (specs: string[]) => number; rankWeight: (specs: string[]) => number; mode: OptimizationMode; onPin: (courseId: string) => void; onUnpin: () => void; openPopoverId: string | null; onOpenPopover: (courseId: string, rect: DOMRect) => void; onClosePopover: () => void; onHoverOpen: (courseId: string, rect: DOMRect) => void; onHoverLeave: () => void; }) { const courses = coursesBySet[setId]; const isPinned = pinnedCourseId != null; const pinnedCourse = isPinned ? courses.find((c) => c.id === pinnedCourseId) : null; // Build a map from courseId to ceiling choice data const ceilingMap = new Map( (analysis?.choices ?? []).map((ch) => [ch.courseId, ch]), ); const hasHighImpact = analysis && analysis.impact > 0; // Determine the recommended choice. Mode-dependent comparison matches the // top-K comparator: priority-order ranks by (rankWeight, count); max-count by (count, rankWeight). // Lex rank weight: a single high-priority spec dominates any combination of lower ones. let recommendedCourseId: string | null = null; if (analysis && analysis.choices.length > 0) { let best: { id: string; count: number; weight: number } | null = null; for (const ch of analysis.choices) { if (!ch.evaluated) continue; const weight = rankWeight(ch.ceilingSpecs); const isBetter = !best || (mode === 'priority-order' ? weight > best.weight || (weight === best.weight && ch.ceilingCount > best.count) : ch.ceilingCount > best.count || (ch.ceilingCount === best.count && weight > best.weight)); if (isBetter) { best = { id: ch.courseId, count: ch.ceilingCount, weight }; } } if (best && (best.count > 0 || best.weight > 0)) recommendedCourseId = best.id; } // Per-set still-searching indicator: search in progress AND at least one cell unevaluated const setSearching = loading && !!analysis && analysis.choices.some((c) => !c.evaluated); return (

{setName} {!isPinned && setSearching && ( )} {!isPinned && hasHighImpact && ( high impact )}

{!isPinned && ( top outcome if picked ↓ )} {isPinned && ( )}
{/* Pinned view */}
{pinnedCourse?.name}
{/* Course list view */}
{courses.map((course) => { const isCancelled = !!course.cancelled; const isDisabled = disabledCourseIds.has(course.id); const isUnavailable = isCancelled || isDisabled; const ceiling = ceilingMap.get(course.id); const reqFor = requiredForSpec[course.id]; const showSkeleton = loading && !analysis; const cellSearching = !!ceiling && !ceiling.evaluated; const isRecommended = recommendedCourseId === course.id; const hasInfo = !!COURSE_DESCRIPTIONS[course.id]; return ( ); })}
); } const skeletonStyle = ` @keyframes skeleton-pulse { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } } @keyframes cell-pulse { 0%, 100% { opacity: 0.4; } 50% { opacity: 1; } } @keyframes spin { to { transform: rotate(360deg); } } `; export function CourseSelection({ pinnedCourses, treeResults, treeLoading, disabledCourseIds, ranking, mode, onPin, onUnpin, onClearAll }: CourseSelectionProps) { const scorer = useMemo(() => makePriorityScorer(ranking), [ranking]); const rankWeight = useMemo(() => makePriorityRankWeight(ranking), [ranking]); const terms: Term[] = ['Spring', 'Summer', 'Fall']; const hasPinned = Object.keys(pinnedCourses).length > 0; // Index tree results by setId for O(1) lookup const treeBySet = new Map(treeResults.map((a) => [a.setId, a])); // Single popover state: only one open at a time const [popover, setPopover] = useState<{ courseId: string; courseName: string; anchorRect: DOMRect } | null>(null); const hoverCloseTimer = useRef | null>(null); const cancelHoverClose = useCallback(() => { if (hoverCloseTimer.current) { clearTimeout(hoverCloseTimer.current); hoverCloseTimer.current = null; } }, []); const handleOpenPopover = useCallback((courseId: string, rect: DOMRect) => { cancelHoverClose(); const allCourses = Object.values(coursesBySet).flat(); const course = allCourses.find(c => c.id === courseId); setPopover({ courseId, courseName: course?.name ?? '', anchorRect: rect }); }, [cancelHoverClose]); const handleClosePopover = useCallback(() => { cancelHoverClose(); setPopover(null); }, [cancelHoverClose]); // Hover open: same as click open but cancels any pending close const handleHoverOpen = useCallback((courseId: string, rect: DOMRect) => { cancelHoverClose(); const allCourses = Object.values(coursesBySet).flat(); const course = allCourses.find(c => c.id === courseId); setPopover({ courseId, courseName: course?.name ?? '', anchorRect: rect }); }, [cancelHoverClose]); // Hover leave: delayed close so mouse can move from icon to popover const handleHoverLeave = useCallback(() => { hoverCloseTimer.current = setTimeout(() => { setPopover(null); }, 150); }, []); return (

Course Selection

Select one course per elective slot. Analysis shows how each choice affects your specializations.

{hasPinned && ( )}
{terms.map((term) => (

{term}

{ELECTIVE_SETS.filter((s) => s.term === term).map((set) => ( onPin(set.id, courseId)} onUnpin={() => onUnpin(set.id)} openPopoverId={popover?.courseId ?? null} onOpenPopover={handleOpenPopover} onClosePopover={handleClosePopover} onHoverOpen={handleHoverOpen} onHoverLeave={handleHoverLeave} /> ))}
))} {popover && ( )}
); }