cb49123930
The v1.3.0 saturation termination silently capped the search after only
the heuristic-favored part of the tree, leaving most per-set ceiling cells
stuck at "0 specs" and hiding genuinely-feasible 3-spec plans in
maximize-count mode. Replace with full exhaustive enumeration plus a
batch of UX refinements that emerged during testing.
Algorithm:
- Drop the saturation early-termination entirely. Search now runs the
full open-set cartesian product to completion; the iteration cap is
also removed so no scenario exits partial.
- Add mode-dependent DFS child ordering: priority-order keeps the
priority-target-first heuristic; maximize-count orders children by
descending count of qualifications for reachable specs (generalist
courses tried first).
- Make the (count, priorityScore) comparator mode-aware: priority-order
ranks by (priorityScore, count) so the user's top spec surfaces;
maximize-count ranks by (count, priorityScore) so the highest count
wins. The same rule drives both top-K position and per-cell ceiling
selection (and the Recommended badge).
- Add an evaluated boolean to each ChoiceOutcome and set it on first
leaf evaluation. Distinguishes "still searching" from "evaluated, no
specs achieved" so the UI never shows misleading 0 specs for a cell
the search hasn't reached yet.
- Throttled progress events (~100ms) carrying iterations / total leaf
count, drive both the per-set spinner and the global progress bar.
UI:
- Top Plans header shows a horizontal progress bar with
"iterations / total · NN%" while the search runs; collapses to
"Search complete · N explored" on completion.
- Per-set spinner next to each elective set heading while any choice
in that set is unevaluated.
- Per-cell pulsing dot + "searching" text for unevaluated cells.
- Replace the "(HCR, BNK, ...)" text labels on each course with
color-coded SpecTag pills using a new fixed per-spec palette
(app/src/data/specColors.ts). Same palette applied to the Top Plans
achievement badges so the two views are visually consistent.
- "Top outcome if picked ↓" caption above the right side of each open
elective set so the spec tags are clearly identified as decision-tree
outcomes (not the course's own qualifications).
- Recommended badge moved inline next to the course name (instead of
on a separate row below) to keep button heights stable.
Tests:
- Replace the saturation early-termination test with an exhaustion test
asserting every cell ends with evaluated: true and partial: false.
- Add mode-dependent ordering test (max-count visits Climate Finance
before Corporate Governance in fall3).
- Add evaluated-flag transition test.
- Add throttled progress-event test (>= ~100ms between consecutive
emits).
- Performance smoke updated to a 60s budget for the exhaustive
user-scenario search; 8-open-set typical case completes in ~7s.
Files: solver/decisionTree.ts, solver/priority.ts (already shipped),
data/specColors.ts (new), components/{TopPlans,CourseSelection}.tsx,
state/appState.ts, workers/decisionTree.worker.ts,
__tests__/searchDecisionTree.test.ts, vite.config.ts, CHANGELOG.md,
openspec/changes/decision-tree-exhaustive-search/* (full change spec).
608 lines
23 KiB
TypeScript
608 lines
23 KiB
TypeScript
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 (
|
|
<span
|
|
title={specNameById[specId] ?? specId}
|
|
style={{
|
|
display: 'inline-block',
|
|
fontSize: '9px', fontWeight: 700, letterSpacing: '0.2px',
|
|
padding: '1px 5px', borderRadius: '3px',
|
|
background: c.bg, color: c.fg, border: `1px solid ${c.border}`,
|
|
whiteSpace: 'nowrap', lineHeight: '1.3',
|
|
}}
|
|
>
|
|
{specId}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
// Reverse map: courseId → specialization names that require it
|
|
const requiredForSpec: Record<string, string[]> = {};
|
|
for (const spec of SPECIALIZATIONS) {
|
|
if (spec.requiredCourseId) {
|
|
(requiredForSpec[spec.requiredCourseId] ??= []).push(spec.name);
|
|
}
|
|
}
|
|
|
|
// specId → full spec name
|
|
const specNameById: Record<string, string> = {};
|
|
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<HTMLDivElement>(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 && (
|
|
<div style={{
|
|
position: 'fixed', inset: 0,
|
|
background: 'rgba(0,0,0,0.3)',
|
|
zIndex: 999,
|
|
}} />
|
|
)}
|
|
<div
|
|
ref={popoverRef}
|
|
onMouseEnter={onHoverEnter}
|
|
onMouseLeave={onHoverLeave}
|
|
style={{
|
|
...positionStyle,
|
|
zIndex: 1000,
|
|
background: '#fff',
|
|
border: '1px solid #d1d5db',
|
|
borderRadius: '8px',
|
|
boxShadow: '0 4px 16px rgba(0,0,0,0.15)',
|
|
padding: '12px',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
}}
|
|
>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '6px' }}>
|
|
<div style={{ fontWeight: 600, fontSize: '14px', color: '#1e293b', flex: 1, paddingRight: '8px' }}>
|
|
{courseName}
|
|
</div>
|
|
<button
|
|
onClick={onClose}
|
|
style={{
|
|
background: 'none', border: 'none', cursor: 'pointer',
|
|
fontSize: '16px', color: '#94a3b8', padding: '0 2px',
|
|
lineHeight: 1, flexShrink: 0,
|
|
}}
|
|
aria-label="Close"
|
|
>
|
|
×
|
|
</button>
|
|
</div>
|
|
{instructorText && (
|
|
<div style={{ fontSize: '12px', color: '#6366f1', marginBottom: '6px', fontWeight: 500 }}>
|
|
{instructorLabel}: {instructorText}
|
|
</div>
|
|
)}
|
|
{courseById[courseId]?.qualifications.length > 0 && (
|
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '4px', marginBottom: '8px' }}>
|
|
{courseById[courseId].qualifications.map((q) => (
|
|
<span
|
|
key={q.specId}
|
|
title={specNameById[q.specId]}
|
|
style={{
|
|
fontSize: '10px', fontWeight: 600,
|
|
padding: '2px 6px', borderRadius: '3px',
|
|
background: '#f0f0ff', color: '#4338ca',
|
|
border: '1px solid #e0e0ff',
|
|
whiteSpace: 'nowrap',
|
|
}}
|
|
>
|
|
{q.specId}{q.marker !== 'standard' ? ` (${q.marker})` : ''}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
<div style={{
|
|
fontSize: '12px', color: '#475569', lineHeight: 1.5,
|
|
overflowY: 'auto', flex: 1,
|
|
whiteSpace: 'pre-line',
|
|
}}>
|
|
{info.description}
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
interface CourseSelectionProps {
|
|
pinnedCourses: Record<string, string | null>;
|
|
treeResults: SetAnalysis[];
|
|
treeLoading: boolean;
|
|
disabledCourseIds: Set<string>;
|
|
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<string>;
|
|
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 (
|
|
<div
|
|
style={{
|
|
border: isPinned ? '1px solid #3b82f6' : '1px solid #ccc',
|
|
borderStyle: isPinned ? 'solid' : 'dashed',
|
|
borderRadius: '8px',
|
|
padding: '12px',
|
|
marginBottom: '8px',
|
|
background: isPinned ? '#eff6ff' : '#fafafa',
|
|
transition: 'border-color 200ms, background-color 200ms',
|
|
}}
|
|
>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' }}>
|
|
<h4 style={{ fontSize: '13px', margin: 0, color: '#444', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
<span>{setName}</span>
|
|
{!isPinned && setSearching && (
|
|
<span style={{
|
|
display: 'inline-block', width: '10px', height: '10px', borderRadius: '50%',
|
|
border: '2px solid #cbd5e1', borderTopColor: '#6366f1',
|
|
animation: 'spin 0.8s linear infinite',
|
|
}} aria-label="searching" />
|
|
)}
|
|
{!isPinned && hasHighImpact && (
|
|
<span style={{ fontSize: '11px', color: '#d97706', fontWeight: 400 }}>high impact</span>
|
|
)}
|
|
</h4>
|
|
{!isPinned && (
|
|
<span style={{
|
|
fontSize: '10px', color: '#94a3b8', fontWeight: 500, letterSpacing: '0.3px',
|
|
textTransform: 'uppercase',
|
|
}}>
|
|
top outcome if picked ↓
|
|
</span>
|
|
)}
|
|
{isPinned && (
|
|
<button
|
|
onClick={onUnpin}
|
|
style={{
|
|
fontSize: '11px', border: '1px solid #bfdbfe', background: '#eff6ff',
|
|
color: '#2563eb', cursor: 'pointer', padding: '3px 10px',
|
|
borderRadius: '4px', fontWeight: 500,
|
|
}}
|
|
>
|
|
Clear
|
|
</button>
|
|
)}
|
|
</div>
|
|
{/* Pinned view */}
|
|
<div style={{
|
|
maxHeight: isPinned ? '40px' : '0',
|
|
opacity: isPinned ? 1 : 0,
|
|
overflow: 'hidden',
|
|
transition: 'max-height 250ms ease-out, opacity 200ms',
|
|
}}>
|
|
<div style={{ fontSize: '14px', fontWeight: 600, color: '#1e40af' }}>
|
|
{pinnedCourse?.name}
|
|
</div>
|
|
</div>
|
|
{/* Course list view */}
|
|
<div style={{
|
|
maxHeight: isPinned ? '0' : '500px',
|
|
opacity: isPinned ? 0 : 1,
|
|
overflow: 'hidden',
|
|
pointerEvents: isPinned ? 'none' : 'auto',
|
|
transition: 'max-height 250ms ease-out, opacity 200ms',
|
|
}}>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
|
{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 (
|
|
<button
|
|
key={course.id}
|
|
onClick={isUnavailable ? undefined : () => 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',
|
|
}}
|
|
>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '8px' }}>
|
|
<span style={{
|
|
flex: 1,
|
|
textDecoration: isCancelled ? 'line-through' : 'none',
|
|
fontStyle: isCancelled ? 'italic' : 'normal',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: '4px',
|
|
}}>
|
|
{course.name}
|
|
{isCancelled && (
|
|
<span style={{ fontSize: '11px', color: '#999', marginLeft: '6px', fontStyle: 'normal', textDecoration: 'none' }}>
|
|
(Cancelled)
|
|
</span>
|
|
)}
|
|
{!isCancelled && isDisabled && (
|
|
<span style={{ fontSize: '11px', color: '#999', marginLeft: '6px' }}>
|
|
(Already selected)
|
|
</span>
|
|
)}
|
|
{isRecommended && !isUnavailable && (
|
|
<span
|
|
title="Best outcome among the choices in this set"
|
|
style={{
|
|
fontSize: '10px', color: '#15803d', fontWeight: 600,
|
|
display: 'inline-flex', alignItems: 'center', gap: '2px',
|
|
padding: '0 4px', borderRadius: '3px', background: '#dcfce7',
|
|
border: '1px solid #bbf7d0', lineHeight: 1.2,
|
|
}}
|
|
>
|
|
<span aria-hidden="true">★</span>
|
|
<span>Recommended</span>
|
|
</span>
|
|
)}
|
|
{!isUnavailable && hasInfo && (
|
|
<span
|
|
role="button"
|
|
tabIndex={0}
|
|
aria-label={`Info about ${course.name}`}
|
|
onClick={(e) => {
|
|
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
|
|
</span>
|
|
)}
|
|
</span>
|
|
{!isUnavailable && (showSkeleton || cellSearching) ? (
|
|
<span style={{
|
|
fontSize: '11px', color: '#94a3b8', fontStyle: 'italic',
|
|
display: 'inline-flex', alignItems: 'center', gap: '4px',
|
|
}}>
|
|
<span style={{
|
|
display: 'inline-block', width: '8px', height: '8px', borderRadius: '50%',
|
|
background: '#cbd5e1', animation: 'cell-pulse 1.2s ease-in-out infinite',
|
|
}} />
|
|
searching
|
|
</span>
|
|
) : !isUnavailable && ceiling && ceiling.evaluated ? (
|
|
<span style={{
|
|
display: 'inline-flex', alignItems: 'center', gap: '4px',
|
|
whiteSpace: 'nowrap', flexWrap: 'wrap', justifyContent: 'flex-end',
|
|
}}>
|
|
{ceiling.ceilingSpecs.length === 0 ? (
|
|
<span style={{ fontSize: '11px', color: '#9ca3af', fontStyle: 'italic' }}>
|
|
no specs
|
|
</span>
|
|
) : (
|
|
ceiling.ceilingSpecs.map((s) => <SpecTag key={s} specId={s} />)
|
|
)}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
{reqFor && !isUnavailable && (
|
|
<span style={{ fontSize: '11px', color: '#92400e', marginTop: '2px' }}>
|
|
Required for {reqFor.join(', ')}
|
|
</span>
|
|
)}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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<ReturnType<typeof setTimeout> | 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 (
|
|
<div>
|
|
<style>{skeletonStyle}</style>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
|
|
<div>
|
|
<h2 style={{ fontSize: '16px', margin: 0 }}>Course Selection</h2>
|
|
<p style={{ fontSize: '12px', color: '#888', margin: '2px 0 0' }}>Select one course per elective slot. Analysis shows how each choice affects your specializations.</p>
|
|
</div>
|
|
{hasPinned && (
|
|
<button
|
|
onClick={onClearAll}
|
|
style={{
|
|
fontSize: '12px', border: '1px solid #fecaca', background: '#fef2f2',
|
|
color: '#dc2626', cursor: 'pointer', padding: '3px 10px',
|
|
borderRadius: '4px', fontWeight: 500,
|
|
}}
|
|
>
|
|
Clear All
|
|
</button>
|
|
)}
|
|
</div>
|
|
{terms.map((term) => (
|
|
<div key={term} style={{ marginBottom: '16px' }}>
|
|
<h3 style={{ fontSize: '13px', color: '#888', marginBottom: '8px', textTransform: 'uppercase', letterSpacing: '0.5px' }}>
|
|
{term}
|
|
</h3>
|
|
{ELECTIVE_SETS.filter((s) => s.term === term).map((set) => (
|
|
<ElectiveSet
|
|
key={set.id}
|
|
setId={set.id}
|
|
setName={set.name}
|
|
pinnedCourseId={pinnedCourses[set.id]}
|
|
analysis={treeBySet.get(set.id)}
|
|
loading={treeLoading}
|
|
disabledCourseIds={disabledCourseIds}
|
|
scorer={scorer}
|
|
mode={mode}
|
|
onPin={(courseId) => onPin(set.id, courseId)}
|
|
onUnpin={() => onUnpin(set.id)}
|
|
openPopoverId={popover?.courseId ?? null}
|
|
onOpenPopover={handleOpenPopover}
|
|
onClosePopover={handleClosePopover}
|
|
onHoverOpen={handleHoverOpen}
|
|
onHoverLeave={handleHoverLeave}
|
|
/>
|
|
))}
|
|
</div>
|
|
))}
|
|
{popover && (
|
|
<CourseInfoPopover
|
|
courseId={popover.courseId}
|
|
courseName={popover.courseName}
|
|
anchorRect={popover.anchorRect}
|
|
onClose={handleClosePopover}
|
|
onHoverEnter={cancelHoverClose}
|
|
onHoverLeave={handleHoverLeave}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|