v1.2.0: Add course info popovers, favicon, and viewport-fitted layout

- Course info popovers with description, instructors, and specialization
  tags; opens on hover (desktop) or tap (mobile) with smart positioning
- Page title and graduation cap favicon in NYU Stern purple
- Desktop layout fits viewport without page-level scrolling
This commit is contained in:
2026-03-27 12:23:15 -04:00
parent 99a39a2581
commit 441d61abc3
7 changed files with 520 additions and 6 deletions
+280
View File
@@ -1,6 +1,10 @@
import { useState, useEffect, useRef, useCallback } 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 type { Term } from '../data/types';
import type { SetAnalysis } from '../solver/decisionTree';
@@ -12,6 +16,164 @@ for (const spec of SPECIALIZATIONS) {
}
}
// 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[];
@@ -31,6 +193,11 @@ function ElectiveSet({
disabledCourseIds,
onPin,
onUnpin,
openPopoverId,
onOpenPopover,
onClosePopover,
onHoverOpen,
onHoverLeave,
}: {
setId: string;
setName: string;
@@ -40,6 +207,11 @@ function ElectiveSet({
disabledCourseIds: Set<string>;
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;
@@ -110,6 +282,7 @@ function ElectiveSet({
const ceiling = ceilingMap.get(course.id);
const reqFor = requiredForSpec[course.id];
const showSkeleton = loading && !analysis;
const hasInfo = !!COURSE_DESCRIPTIONS[course.id];
return (
<button
key={course.id}
@@ -131,6 +304,9 @@ function ElectiveSet({
flex: 1,
textDecoration: isCancelled ? 'line-through' : 'none',
fontStyle: isCancelled ? 'italic' : 'normal',
display: 'flex',
alignItems: 'center',
gap: '4px',
}}>
{course.name}
{isCancelled && (
@@ -143,6 +319,57 @@ function ElectiveSet({
(Already selected)
</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 ? (
<span
@@ -193,6 +420,44 @@ export function CourseSelection({ pinnedCourses, treeResults, treeLoading, disab
// 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>
@@ -230,10 +495,25 @@ export function CourseSelection({ pinnedCourses, treeResults, treeLoading, disab
disabledCourseIds={disabledCourseIds}
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>
);
}