v1.4.0: Desktop layout redesign + mobile tabs
Specializations move from a 340px left rail to a horizontal 2-row chip grid at the top (drag L→R to rank). Each chip shows rank, spec-colored abbreviation tag matching the tags used in plans/schedule, full name on its own row, status glyph, and a micro credit bar. Hover/tap a chip to see full status, allocated/threshold credits, and contributing-courses breakdown in a popover. The right pane splits into two side-by-side columns on desktop: Top Plans (left) and Schedule (right), each scrolling independently. The search progress bar hoists into a global strip below the spec grid so it stays visible regardless of which column is scrolled. Schedule blocks render their course choices as a horizontal row of equal-width buttons (3-5 per set) instead of stacked rows. Pinned sets collapse to a single line with the course name inline next to the set title. Term headers (Spring/Summer/Fall) remain as section dividers. On mobile, the layout becomes a 3-tab segmented control (Specializations / Plans / Courses) with the search progress strip above the tabs. The previous floating MobileStatusBanner and MobileCourseBanner are dropped — tabs replace their navigation function.
This commit is contained in:
@@ -216,6 +216,7 @@ function ElectiveSet({
|
||||
scorer,
|
||||
rankWeight,
|
||||
mode,
|
||||
isMobile,
|
||||
onPin,
|
||||
onUnpin,
|
||||
openPopoverId,
|
||||
@@ -233,6 +234,7 @@ function ElectiveSet({
|
||||
scorer: (specs: string[]) => number;
|
||||
rankWeight: (specs: string[]) => number;
|
||||
mode: OptimizationMode;
|
||||
isMobile: boolean;
|
||||
onPin: (courseId: string) => void;
|
||||
onUnpin: () => void;
|
||||
openPopoverId: string | null;
|
||||
@@ -282,15 +284,32 @@ function ElectiveSet({
|
||||
border: isPinned ? '1px solid #3b82f6' : '1px solid #ccc',
|
||||
borderStyle: isPinned ? 'solid' : 'dashed',
|
||||
borderRadius: '8px',
|
||||
padding: '12px',
|
||||
padding: isPinned ? '8px 12px' : '12px',
|
||||
marginBottom: '8px',
|
||||
background: isPinned ? '#eff6ff' : '#fafafa',
|
||||
transition: 'border-color 200ms, background-color 200ms',
|
||||
transition: 'border-color 200ms, background-color 200ms, padding 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>
|
||||
<div style={{
|
||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||
gap: '8px',
|
||||
marginBottom: isPinned ? 0 : '8px',
|
||||
}}>
|
||||
<h4 style={{
|
||||
fontSize: '13px', margin: 0, color: '#444',
|
||||
display: 'flex', alignItems: 'baseline', gap: '8px', flexWrap: 'wrap',
|
||||
minWidth: 0, flex: 1,
|
||||
}}>
|
||||
<span style={{ flexShrink: 0 }}>{setName}{isPinned ? ':' : ''}</span>
|
||||
{isPinned && pinnedCourse && (
|
||||
<span style={{
|
||||
fontSize: '13px', fontWeight: 600, color: '#1e40af',
|
||||
minWidth: 0,
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{pinnedCourse.name}
|
||||
</span>
|
||||
)}
|
||||
{!isPinned && setSearching && (
|
||||
<span style={{
|
||||
display: 'inline-block', width: '10px', height: '10px', borderRadius: '50%',
|
||||
@@ -302,10 +321,11 @@ function ElectiveSet({
|
||||
<span style={{ fontSize: '11px', color: '#d97706', fontWeight: 400 }}>high impact</span>
|
||||
)}
|
||||
</h4>
|
||||
{!isPinned && (
|
||||
{!isPinned && isMobile && (
|
||||
<span style={{
|
||||
fontSize: '10px', color: '#94a3b8', fontWeight: 500, letterSpacing: '0.3px',
|
||||
textTransform: 'uppercase',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
top outcome if picked ↓
|
||||
</span>
|
||||
@@ -317,23 +337,13 @@ function ElectiveSet({
|
||||
fontSize: '11px', border: '1px solid #bfdbfe', background: '#eff6ff',
|
||||
color: '#2563eb', cursor: 'pointer', padding: '3px 10px',
|
||||
borderRadius: '4px', fontWeight: 500,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
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',
|
||||
@@ -342,7 +352,12 @@ function ElectiveSet({
|
||||
pointerEvents: isPinned ? 'none' : 'auto',
|
||||
transition: 'max-height 250ms ease-out, opacity 200ms',
|
||||
}}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: isMobile ? 'column' : 'row',
|
||||
alignItems: isMobile ? 'stretch' : 'stretch',
|
||||
gap: '4px',
|
||||
}}>
|
||||
{courses.map((course) => {
|
||||
const isCancelled = !!course.cancelled;
|
||||
const isDisabled = disabledCourseIds.has(course.id);
|
||||
@@ -353,137 +368,224 @@ function ElectiveSet({
|
||||
const cellSearching = !!ceiling && !ceiling.evaluated;
|
||||
const isRecommended = recommendedCourseId === course.id;
|
||||
const hasInfo = !!COURSE_DESCRIPTIONS[course.id];
|
||||
|
||||
const infoIcon = !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>
|
||||
) : null;
|
||||
|
||||
const ceilingTags = !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',
|
||||
flexWrap: 'wrap', justifyContent: isMobile ? 'flex-end' : 'flex-start',
|
||||
}}>
|
||||
{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;
|
||||
|
||||
if (isMobile) {
|
||||
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>
|
||||
)}
|
||||
{infoIcon}
|
||||
</span>
|
||||
{ceilingTags}
|
||||
</div>
|
||||
{reqFor && !isUnavailable && (
|
||||
<span style={{ fontSize: '11px', color: '#92400e', marginTop: '2px' }}>
|
||||
Required for {reqFor.join(', ')}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// Desktop horizontal button
|
||||
return (
|
||||
<button
|
||||
key={course.id}
|
||||
onClick={isUnavailable ? undefined : () => onPin(course.id)}
|
||||
disabled={isUnavailable}
|
||||
title={course.name}
|
||||
style={{
|
||||
flex: '1 1 0',
|
||||
minWidth: 0,
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'stretch',
|
||||
textAlign: 'left', padding: '6px 10px',
|
||||
border: '1px solid #e5e7eb', borderRadius: '4px',
|
||||
background: isUnavailable ? '#f5f5f5' : '#fff',
|
||||
textAlign: 'left', padding: '6px 8px', gap: '4px',
|
||||
border: isRecommended && !isUnavailable ? '1px solid #bbf7d0' : '1px solid #e5e7eb',
|
||||
borderRadius: '4px',
|
||||
background: isUnavailable ? '#f5f5f5' : isRecommended ? '#f0fdf4' : '#fff',
|
||||
cursor: isUnavailable ? 'default' : 'pointer',
|
||||
fontSize: '13px',
|
||||
fontSize: '12px',
|
||||
color: isUnavailable ? '#bbb' : '#333',
|
||||
pointerEvents: isUnavailable ? 'none' : 'auto',
|
||||
font: 'inherit',
|
||||
}}
|
||||
>
|
||||
<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
|
||||
<div style={{
|
||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||
minHeight: '16px',
|
||||
}}>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center' }}>{infoIcon}</span>
|
||||
{isRecommended && !isUnavailable && (
|
||||
<span
|
||||
title="Best outcome among the choices in this set"
|
||||
aria-label="Recommended"
|
||||
style={{
|
||||
fontSize: '12px', color: '#15803d', lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
★
|
||||
</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>
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
color: isUnavailable ? '#bbb' : '#333',
|
||||
fontWeight: isRecommended && !isUnavailable ? 600 : 500,
|
||||
lineHeight: 1.3,
|
||||
textDecoration: isCancelled ? 'line-through' : 'none',
|
||||
fontStyle: isCancelled ? 'italic' : 'normal',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 3,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
flex: 1,
|
||||
}}>
|
||||
{course.name}
|
||||
</div>
|
||||
{ceilingTags && (
|
||||
<div style={{
|
||||
display: 'flex', flexWrap: 'wrap', gap: '3px',
|
||||
marginTop: 'auto', minHeight: '18px',
|
||||
}}>
|
||||
{ceilingTags}
|
||||
</div>
|
||||
)}
|
||||
{isCancelled && (
|
||||
<span style={{ fontSize: '10px', color: '#999', fontStyle: 'normal', textDecoration: 'none' }}>
|
||||
Cancelled
|
||||
</span>
|
||||
)}
|
||||
{!isCancelled && isDisabled && (
|
||||
<span style={{ fontSize: '10px', color: '#999' }}>
|
||||
Already selected
|
||||
</span>
|
||||
)}
|
||||
{reqFor && !isUnavailable && (
|
||||
<span style={{ fontSize: '11px', color: '#92400e', marginTop: '2px' }}>
|
||||
Required for {reqFor.join(', ')}
|
||||
<span style={{ fontSize: '10px', color: '#92400e', lineHeight: 1.3 }}>
|
||||
Req. for {reqFor.join(', ')}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
@@ -506,6 +608,8 @@ export function CourseSelection({ pinnedCourses, treeResults, treeLoading, disab
|
||||
const rankWeight = useMemo(() => makePriorityRankWeight(ranking), [ranking]);
|
||||
const terms: Term[] = ['Spring', 'Summer', 'Fall'];
|
||||
const hasPinned = Object.keys(pinnedCourses).length > 0;
|
||||
const breakpoint = useMediaQuery();
|
||||
const isMobile = breakpoint === 'mobile';
|
||||
|
||||
// Index tree results by setId for O(1) lookup
|
||||
const treeBySet = new Map(treeResults.map((a) => [a.setId, a]));
|
||||
@@ -586,6 +690,7 @@ export function CourseSelection({ pinnedCourses, treeResults, treeLoading, disab
|
||||
scorer={scorer}
|
||||
rankWeight={rankWeight}
|
||||
mode={mode}
|
||||
isMobile={isMobile}
|
||||
onPin={(courseId) => onPin(set.id, courseId)}
|
||||
onUnpin={() => onUnpin(set.id)}
|
||||
openPopoverId={popover?.courseId ?? null}
|
||||
|
||||
Reference in New Issue
Block a user