Files
emba-course-solver/app/src/components/CourseSelection.tsx
T
Bill b282709476 v1.3.3: Lex priority comparator + warm-cache cap + score display
The v1.3.1 comparator used a sum-of-weights priorityScore. With weights
15..1 across 15 specs, three lower-priority specs (BNK+BRM+CRF, sum 39)
could outrank a single top-priority spec (HCR alone, sum 15). In
priority-order mode this surfaced lower-priority plans above the user's
top spec — the opposite of intent.

Fix: replace sum-of-weights with a lexicographic rank weight. Each spec
encodes as a bit, top-ranked spec = highest bit. So [HCR] = 16384 beats
[BNK,BRM,CRF,EMT,ENT,FIN,FIM,GLB,LCM,MGT,MKT,MTO,SBI,STR] = 16383. A plan
containing a higher-ranked spec ALWAYS outranks any plan that doesn't,
regardless of how many lower-ranked specs the latter contains. Lower
specs only act as tiebreakers among plans that all contain the same
higher-ranked spec.

Both modes use lex weight as the priority key; modes still differ in
ordering:
  priority-order: (rankWeight desc, count desc, key asc)
  maximize-count: (count desc, rankWeight desc, key asc)

Score display changes from the legacy sum (e.g. "score 29") to the lex
weight in compact form (e.g. "score 24.6k"). Hover for full integer.
The display now actually corresponds to ranking order.

Other:

- Cache cap (500k leaves) now retains existing entries instead of
  clearing on overflow. New entries past the cap are dropped; the
  cached subset stays available as a warm starting point.
- Two new lex-weight tests in searchDecisionTree.test.ts:
  - single top-ranked spec outweighs all 14 others combined
  - tiebreaker is the next-ranked spec
- All 84 tests pass; cached leaves stay valid across the comparator
  change since achievedSpecs (the input to lex compare) is unchanged.

Files: solver/priority.ts (new functions), solver/decisionTree.ts
(comparators take ranking), components/{TopPlans,CourseSelection}.tsx
(score display + Recommended badge), state/appState.ts (cache-cap
behavior), vite.config.ts, CHANGELOG.md.
2026-05-09 16:51:54 -04:00

613 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, 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 (
<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"
>
&times;
</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,
rankWeight,
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;
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 (
<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 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<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}
rankWeight={rankWeight}
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>
);
}