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 } 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, 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; 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 (score, count); max-count by (count, score). let recommendedCourseId: string | null = null; if (analysis && analysis.choices.length > 0) { let best: { id: string; count: number; score: number } | null = null; for (const ch of analysis.choices) { if (!ch.evaluated) continue; const score = scorer(ch.ceilingSpecs); const isBetter = !best || (mode === 'priority-order' ? score > best.score || (score === best.score && ch.ceilingCount > best.count) : ch.ceilingCount > best.count || (ch.ceilingCount === best.count && score > best.score)); if (isBetter) { best = { id: ch.courseId, count: ch.ceilingCount, score }; } } if (best && (best.count > 0 || best.score > 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 && ( Clear )} {/* 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 ( onPin(course.id)} disabled={isUnavailable} style={{ display: 'flex', flexDirection: 'column', alignItems: 'stretch', textAlign: 'left', padding: '6px 10px', border: '1px solid #e5e7eb', borderRadius: '4px', background: isUnavailable ? '#f5f5f5' : '#fff', cursor: isUnavailable ? 'default' : 'pointer', fontSize: '13px', color: isUnavailable ? '#bbb' : '#333', pointerEvents: isUnavailable ? 'none' : 'auto', }} > {course.name} {isCancelled && ( (Cancelled) )} {!isCancelled && isDisabled && ( (Already selected) )} {isRecommended && !isUnavailable && ( ★ Recommended )} {!isUnavailable && hasInfo && ( { e.stopPropagation(); e.preventDefault(); if (openPopoverId === course.id) { onClosePopover(); } else { const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); onOpenPopover(course.id, rect); } }} onMouseEnter={(e) => { if (window.matchMedia('(hover: hover)').matches) { const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); onHoverOpen(course.id, rect); } }} onMouseLeave={() => { if (window.matchMedia('(hover: hover)').matches) { onHoverLeave(); } }} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.stopPropagation(); e.preventDefault(); if (openPopoverId === course.id) { onClosePopover(); } else { const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); onOpenPopover(course.id, rect); } } }} style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: '16px', height: '16px', borderRadius: '50%', border: '1px solid #cbd5e1', background: openPopoverId === course.id ? '#e0e7ff' : '#f1f5f9', color: '#6366f1', fontSize: '10px', fontWeight: 700, cursor: 'pointer', flexShrink: 0, fontStyle: 'normal', textDecoration: 'none', lineHeight: 1, }} > i )} {!isUnavailable && (showSkeleton || cellSearching) ? ( searching ) : !isUnavailable && ceiling && ceiling.evaluated ? ( {ceiling.ceilingSpecs.length === 0 ? ( no specs ) : ( ceiling.ceilingSpecs.map((s) => ) )} ) : null} {reqFor && !isUnavailable && ( Required for {reqFor.join(', ')} )} ); })} ); } 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 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 && ( Clear All )} {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 && ( )} ); }
Select one course per elective slot. Analysis shows how each choice affects your specializations.