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:
2026-05-09 17:45:28 -04:00
parent b282709476
commit 2ebfb9d2ec
14 changed files with 1293 additions and 205 deletions
+124 -66
View File
@@ -1,4 +1,4 @@
import { useMemo, useRef, useState, useEffect, useCallback } from 'react';
import { useMemo, useRef, useState } from 'react';
import { useAppState } from './state/appState';
import { useMediaQuery } from './hooks/useMediaQuery';
import { SpecializationRanking } from './components/SpecializationRanking';
@@ -6,11 +6,12 @@ import { ModeToggle } from './components/ModeToggle';
import { CourseSelection } from './components/CourseSelection';
import { CreditLegend } from './components/CreditLegend';
import { TopPlans } from './components/TopPlans';
import { SearchProgressStrip } from './components/SearchProgressStrip';
import { ModeComparison } from './components/Notifications';
import { MobileStatusBanner } from './components/MobileStatusBanner';
import { MobileCourseBanner } from './components/MobileCourseBanner';
import { optimize } from './solver/optimizer';
type MobileTab = 'specs' | 'plans' | 'courses';
function App() {
const {
state,
@@ -43,43 +44,9 @@ function App() {
const isMobile = breakpoint === 'mobile';
const [mobileTab, setMobileTab] = useState<MobileTab>('specs');
const specSectionRef = useRef<HTMLDivElement>(null);
const [bannerVisible, setBannerVisible] = useState(false);
useEffect(() => {
if (!isMobile || !specSectionRef.current) {
setBannerVisible(false);
return;
}
const observer = new IntersectionObserver(
([entry]) => setBannerVisible(!entry.isIntersecting),
);
observer.observe(specSectionRef.current);
return () => observer.disconnect();
}, [isMobile]);
const handleBannerTap = useCallback(() => {
specSectionRef.current?.scrollIntoView({ behavior: 'smooth' });
}, []);
const courseSectionRef = useRef<HTMLDivElement>(null);
const [courseBannerVisible, setCourseBannerVisible] = useState(false);
useEffect(() => {
if (!isMobile || !courseSectionRef.current) {
setCourseBannerVisible(false);
return;
}
const observer = new IntersectionObserver(
([entry]) => setCourseBannerVisible(!entry.isIntersecting),
);
observer.observe(courseSectionRef.current);
return () => observer.disconnect();
}, [isMobile]);
const handleCourseBannerTap = useCallback(() => {
courseSectionRef.current?.scrollIntoView({ behavior: 'smooth' });
}, []);
const containerStyle: React.CSSProperties = {
maxWidth: '1200px',
@@ -89,27 +56,114 @@ function App() {
...(isMobile ? {} : { height: '100vh', display: 'flex', flexDirection: 'column', overflow: 'hidden' }),
};
const panelStyle: React.CSSProperties = isMobile
? { display: 'flex', flexDirection: 'column', gap: '20px' }
: { display: 'grid', gridTemplateColumns: '340px 1fr', gap: '24px', alignItems: 'stretch', flex: 1, minHeight: 0 };
if (isMobile) {
return (
<div style={containerStyle}>
<h1 style={{ fontSize: '20px', marginBottom: '2px', color: '#111' }}>
EMBA Specialization Solver
</h1>
<div style={{ fontSize: '11px', color: '#999', marginBottom: '12px' }}>v{__APP_VERSION__} ({__APP_VERSION_DATE__})</div>
<ModeToggle mode={state.mode} onSetMode={setMode} />
<ModeComparison
result={optimizationResult}
altResult={altResult}
altModeName={altMode === 'maximize-count' ? 'Maximize Count' : 'Priority Order'}
/>
<SearchProgressStrip loading={treeLoading} progress={searchProgress} />
<div role="tablist" aria-label="Sections" style={{
display: 'grid', gridTemplateColumns: '1fr 1fr 1fr',
gap: '4px', marginBottom: '12px',
background: '#f1f5f9', borderRadius: '8px', padding: '3px',
}}>
{([
{ id: 'specs', label: 'Specializations' },
{ id: 'plans', label: 'Plans' },
{ id: 'courses', label: 'Courses' },
] as const).map((t) => {
const active = mobileTab === t.id;
return (
<button
key={t.id}
role="tab"
aria-selected={active}
onClick={() => setMobileTab(t.id)}
style={{
fontSize: '12px', fontWeight: 600,
padding: '8px 4px',
border: 'none', borderRadius: '6px',
background: active ? '#fff' : 'transparent',
color: active ? '#1e293b' : '#64748b',
boxShadow: active ? '0 1px 2px rgba(0,0,0,0.08)' : 'none',
cursor: 'pointer',
transition: 'background 150ms, color 150ms',
}}
>
{t.label}
</button>
);
})}
</div>
{mobileTab === 'specs' && (
<div role="tabpanel" ref={specSectionRef}>
<CreditLegend />
<SpecializationRanking
ranking={state.ranking}
result={optimizationResult}
onReorder={reorder}
/>
</div>
)}
{mobileTab === 'plans' && (
<div role="tabpanel">
<TopPlans
plans={topPlans}
partial={topPlansPartial}
loading={treeLoading}
progress={searchProgress}
pinnedCourses={state.pinnedCourses}
ranking={state.ranking}
showAnimatedBar={false}
onAdopt={adoptPlan}
onPin={pinCourse}
onUnpin={unpinCourse}
/>
</div>
)}
{mobileTab === 'courses' && (
<div role="tabpanel" ref={courseSectionRef}>
<CourseSelection
pinnedCourses={state.pinnedCourses}
treeResults={treeResults}
treeLoading={treeLoading}
disabledCourseIds={disabledCourseIds}
ranking={state.ranking}
mode={state.mode}
onPin={pinCourse}
onUnpin={unpinCourse}
onClearAll={clearAll}
/>
</div>
)}
</div>
);
}
// Desktop layout: top spec strip + global progress strip + 2-col workspace
const workspaceStyle: React.CSSProperties = {
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '24px',
flex: 1,
minHeight: 0,
};
return (
<div style={containerStyle}>
{isMobile && (
<>
<MobileStatusBanner
statuses={optimizationResult.statuses}
visible={bannerVisible}
onTap={handleBannerTap}
/>
<MobileCourseBanner
selectedCount={Object.keys(state.pinnedCourses).length}
totalSets={12}
visible={courseBannerVisible}
onTap={handleCourseBannerTap}
/>
</>
)}
<h1 style={{ fontSize: '20px', marginBottom: '2px', color: '#111' }}>
EMBA Specialization Solver
</h1>
@@ -123,16 +177,17 @@ function App() {
altModeName={altMode === 'maximize-count' ? 'Maximize Count' : 'Priority Order'}
/>
<div style={panelStyle}>
<div ref={specSectionRef} style={isMobile ? {} : { overflowY: 'auto', minHeight: 0 }}>
<CreditLegend />
<SpecializationRanking
ranking={state.ranking}
result={optimizationResult}
onReorder={reorder}
/>
</div>
<div ref={courseSectionRef} style={isMobile ? {} : { overflowY: 'auto', minHeight: 0 }}>
<SpecializationRanking
ranking={state.ranking}
result={optimizationResult}
onReorder={reorder}
headerSlot={<CreditLegend />}
/>
<SearchProgressStrip loading={treeLoading} progress={searchProgress} />
<div style={workspaceStyle}>
<div style={{ overflowY: 'auto', minHeight: 0, paddingRight: '4px' }}>
<TopPlans
plans={topPlans}
partial={topPlansPartial}
@@ -140,10 +195,13 @@ function App() {
progress={searchProgress}
pinnedCourses={state.pinnedCourses}
ranking={state.ranking}
showAnimatedBar={false}
onAdopt={adoptPlan}
onPin={pinCourse}
onUnpin={unpinCourse}
/>
</div>
<div style={{ overflowY: 'auto', minHeight: 0, paddingRight: '4px' }}>
<CourseSelection
pinnedCourses={state.pinnedCourses}
treeResults={treeResults}
+239 -134
View File
@@ -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}
@@ -0,0 +1,40 @@
interface SearchProgressStripProps {
loading: boolean;
progress: { iterations: number; iterationsTotal: number } | null;
}
function formatNum(n: number): string {
return n.toLocaleString();
}
export function SearchProgressStrip({ loading, progress }: SearchProgressStripProps) {
if (!loading || !progress) return null;
const pct = progress.iterationsTotal > 0
? Math.min(100, (progress.iterations / progress.iterationsTotal) * 100)
: 0;
return (
<div style={{ margin: '4px 0 12px' }}>
<div style={{
position: 'relative', height: '6px', background: '#e5e7eb',
borderRadius: '3px', overflow: 'hidden',
}}>
<div style={{
position: 'absolute', top: 0, left: 0, height: '100%',
width: `${pct}%`, background: '#3b82f6',
transition: 'width 150ms ease-out',
}} />
</div>
<div style={{
display: 'flex', justifyContent: 'space-between',
fontSize: '10px', color: '#888', marginTop: '2px',
}}>
<span>Searching</span>
<span>
{formatNum(progress.iterations)} / {formatNum(progress.iterationsTotal)}
{' · '}{Math.round(pct)}%
</span>
</div>
</div>
);
}
+381 -2
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from 'react';
import { useState, useEffect, useRef, useCallback } from 'react';
import {
DndContext,
closestCenter,
@@ -14,11 +14,14 @@ import {
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
rectSortingStrategy,
arrayMove,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { SPECIALIZATIONS } from '../data/specializations';
import { courseById } from '../data/lookups';
import { specColor } from '../data/specColors';
import { useMediaQuery } from '../hooks/useMediaQuery';
import type { SpecStatus, AllocationResult } from '../data/types';
export const STATUS_STYLES: Record<SpecStatus, { bg: string; color: string; label: string }> = {
@@ -217,10 +220,14 @@ function SortableItem({ id, rank, total, name, status, allocated, potential, isE
interface SpecializationRankingProps {
ranking: string[];
result: AllocationResult;
headerSlot?: React.ReactNode;
onReorder: (ranking: string[]) => void;
}
export function SpecializationRanking({ ranking, result, onReorder }: SpecializationRankingProps) {
export function SpecializationRanking({ ranking, result, headerSlot, onReorder }: SpecializationRankingProps) {
const breakpoint = useMediaQuery();
const isMobile = breakpoint === 'mobile';
const [expanded, setExpanded] = useState<Set<string>>(() => new Set(result.achieved));
const prevAchievedRef = useRef(result.achieved);
@@ -271,6 +278,24 @@ export function SpecializationRanking({ ranking, result, onReorder }: Specializa
}
const specMap = new Map(SPECIALIZATIONS.map((s) => [s.id, s]));
const achievedSummary = result.achieved.length > 0
? `${result.achieved.length} of ${ranking.length} achieved`
: 'No specializations achieved yet';
if (!isMobile) {
return (
<DesktopSpecStrip
ranking={ranking}
result={result}
sensors={sensors}
onDragEnd={handleDragEnd}
getAllocatedCredits={getAllocatedCredits}
specMap={specMap}
achievedSummary={achievedSummary}
headerSlot={headerSlot}
/>
);
}
return (
<div>
@@ -305,3 +330,357 @@ export function SpecializationRanking({ ranking, result, onReorder }: Specializa
</div>
);
}
// ===== Desktop strip variant =====
interface DesktopSpecStripProps {
ranking: string[];
result: AllocationResult;
sensors: ReturnType<typeof useSensors>;
onDragEnd: (event: DragEndEvent) => void;
getAllocatedCredits: (specId: string) => number;
specMap: Map<string, { id: string; name: string; abbreviation: string }>;
achievedSummary: string;
headerSlot?: React.ReactNode;
}
function DesktopSpecStrip({ ranking, result, sensors, onDragEnd, getAllocatedCredits, specMap, achievedSummary, headerSlot }: DesktopSpecStripProps) {
const [popover, setPopover] = useState<{ specId: 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 openPopover = useCallback((specId: string, rect: DOMRect) => {
cancelHoverClose();
setPopover({ specId, anchorRect: rect });
}, [cancelHoverClose]);
const closePopover = useCallback(() => {
cancelHoverClose();
setPopover(null);
}, [cancelHoverClose]);
const handleHoverLeave = useCallback(() => {
hoverCloseTimer.current = setTimeout(() => {
setPopover(null);
}, 150);
}, []);
return (
<div style={{ marginBottom: '12px' }}>
<div style={{
display: 'flex', alignItems: 'baseline', justifyContent: 'space-between',
gap: '12px', marginBottom: '6px', flexWrap: 'wrap',
}}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: '12px', flexWrap: 'wrap' }}>
<h2 style={{ fontSize: '16px', margin: 0 }}>Specializations</h2>
<span style={{ fontSize: '11px', color: '#888' }}>
drag left right to rank · {achievedSummary}
</span>
</div>
{headerSlot && <div style={{ display: 'flex', alignItems: 'baseline' }}>{headerSlot}</div>}
</div>
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
<SortableContext items={ranking} strategy={rectSortingStrategy}>
<div style={{
display: 'grid',
gridTemplateColumns: `repeat(${Math.ceil(ranking.length / 2)}, minmax(0, 1fr))`,
gap: '6px',
paddingBottom: '4px',
}}>
{ranking.map((id, i) => (
<SpecChip
key={id}
id={id}
rank={i + 1}
name={specMap.get(id)?.name ?? id}
abbreviation={specMap.get(id)?.abbreviation ?? id}
status={result.statuses[id]}
allocated={getAllocatedCredits(id)}
potential={result.upperBounds[id] || 0}
isOpen={popover?.specId === id}
onHoverOpen={openPopover}
onHoverLeave={handleHoverLeave}
onTapToggle={(specId, rect) => {
if (popover?.specId === specId) closePopover();
else openPopover(specId, rect);
}}
/>
))}
</div>
</SortableContext>
</DndContext>
{popover && (
<SpecChipPopover
specId={popover.specId}
name={specMap.get(popover.specId)?.name ?? popover.specId}
status={result.statuses[popover.specId]}
allocated={getAllocatedCredits(popover.specId)}
allocations={result.allocations}
anchorRect={popover.anchorRect}
onClose={closePopover}
onHoverEnter={cancelHoverClose}
onHoverLeave={handleHoverLeave}
/>
)}
</div>
);
}
interface SpecChipProps {
id: string;
rank: number;
name: string;
abbreviation: string;
status: SpecStatus;
allocated: number;
potential: number;
isOpen: boolean;
onHoverOpen: (specId: string, rect: DOMRect) => void;
onHoverLeave: () => void;
onTapToggle: (specId: string, rect: DOMRect) => void;
}
function SpecChip({ id, rank, name, abbreviation, status, allocated, potential, isOpen, onHoverOpen, onHoverLeave, onTapToggle }: SpecChipProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition: dndTransition,
isDragging,
} = useSortable({ id });
const style = STATUS_STYLES[status] || STATUS_STYLES.unreachable;
const tagColor = specColor(id);
const threshold = 9;
const denom = Math.max(potential, threshold);
const allocPct = Math.min((allocated / denom) * 100, 100);
const potentialPct = Math.min((potential / denom) * 100, 100);
const thresholdPct = Math.min((threshold / denom) * 100, 100);
const handleMouseEnter = (e: React.MouseEvent<HTMLDivElement>) => {
if (window.matchMedia('(hover: hover)').matches) {
const rect = e.currentTarget.getBoundingClientRect();
onHoverOpen(id, rect);
}
};
const handleMouseLeave = () => {
if (window.matchMedia('(hover: hover)').matches) {
onHoverLeave();
}
};
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
const rect = e.currentTarget.getBoundingClientRect();
onTapToggle(id, rect);
};
const statusGlyph =
status === 'achieved' ? '✓' :
status === 'achievable' ? '·' :
status === 'missing_required' ? '!' : '—';
return (
<div
ref={setNodeRef}
{...attributes}
{...listeners}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onClick={handleClick}
title={name}
style={{
transform: CSS.Transform.toString(transform),
transition: dndTransition,
opacity: isDragging ? 0.5 : 1,
minWidth: 0,
height: '70px',
borderRadius: '6px',
background: isDragging ? '#e8e8e8' : style.bg,
border: isOpen ? `2px solid ${style.color}` : '1px solid #ddd',
padding: isOpen ? '5px 7px' : '6px 8px',
cursor: 'grab',
touchAction: 'none',
userSelect: 'none',
display: 'flex',
flexDirection: 'column',
alignItems: 'stretch',
gap: '3px',
boxSizing: 'border-box',
}}
>
<div style={{
display: 'flex', alignItems: 'center', gap: '4px',
fontSize: '11px', lineHeight: 1.2,
}}>
<span style={{ color: '#888', fontWeight: 500 }}>{rank}.</span>
<span style={{
display: 'inline-block',
fontSize: '9px', fontWeight: 700, letterSpacing: '0.2px',
padding: '1px 4px', borderRadius: '3px',
background: tagColor.bg, color: tagColor.fg,
border: `1px solid ${tagColor.border}`,
whiteSpace: 'nowrap', lineHeight: '1.3',
}}>
{abbreviation}
</span>
<span style={{ color: style.color, fontWeight: 700, marginLeft: 'auto' }} aria-label={style.label}>
{statusGlyph}
</span>
</div>
<div style={{
fontSize: '11px', fontWeight: 600, color: '#333',
lineHeight: 1.2,
maxHeight: '2.4em',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}>
{name}
</div>
<div style={{
position: 'relative', height: '4px', background: '#e5e7eb',
borderRadius: '2px', overflow: 'hidden', marginTop: 'auto',
}}>
{potential > allocated && (
<div style={{
position: 'absolute', left: 0, top: 0, height: '100%',
width: `${potentialPct}%`, background: '#bfdbfe',
transition: 'width 300ms ease-out',
}} />
)}
<div style={{
position: 'absolute', left: 0, top: 0, height: '100%',
width: `${allocPct}%`,
background: allocated >= threshold ? '#22c55e' : '#3b82f6',
transition: 'width 300ms ease-out',
}} />
<div style={{
position: 'absolute', left: `${thresholdPct}%`, top: '-1px',
width: '1px', height: '6px', background: '#666',
}} />
</div>
</div>
);
}
interface SpecChipPopoverProps {
specId: string;
name: string;
status: SpecStatus;
allocated: number;
allocations: Record<string, Record<string, number>>;
anchorRect: DOMRect;
onClose: () => void;
onHoverEnter: () => void;
onHoverLeave: () => void;
}
function SpecChipPopover({ specId, name, status, allocated, allocations, anchorRect, onClose, onHoverEnter, onHoverLeave }: SpecChipPopoverProps) {
const popoverRef = useRef<HTMLDivElement>(null);
const style = STATUS_STYLES[status] || STATUS_STYLES.unreachable;
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') onClose();
}
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, [onClose]);
useEffect(() => {
function onClickOutside(e: MouseEvent) {
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
onClose();
}
}
const id = setTimeout(() => document.addEventListener('mousedown', onClickOutside), 0);
return () => {
clearTimeout(id);
document.removeEventListener('mousedown', onClickOutside);
};
}, [onClose]);
const popoverMaxHeight = 260;
const popoverWidth = 280;
const spaceBelow = window.innerHeight - anchorRect.bottom - 6;
const spaceAbove = anchorRect.top - 6;
const placeAbove = spaceBelow < Math.min(popoverMaxHeight, 150) && spaceAbove > spaceBelow;
const left = Math.max(8, Math.min(anchorRect.left, window.innerWidth - popoverWidth - 8));
const positionStyle: React.CSSProperties = {
position: 'fixed',
left,
width: popoverWidth,
...(placeAbove
? { bottom: window.innerHeight - anchorRect.top + 6, maxHeight: Math.min(popoverMaxHeight, spaceAbove) }
: { top: anchorRect.bottom + 6, maxHeight: Math.min(popoverMaxHeight, spaceBelow) }),
};
const contributions: { courseName: string; credits: number }[] = [];
for (const [courseId, specAlloc] of Object.entries(allocations)) {
const credits = specAlloc[specId];
if (credits && credits > 0) {
const course = courseById[courseId];
contributions.push({ courseName: course?.name ?? courseId, credits });
}
}
return (
<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',
gap: '6px',
}}
>
<div style={{ fontWeight: 600, fontSize: '14px', color: '#1e293b' }}>{name}</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{
fontSize: '11px', padding: '2px 8px', borderRadius: '10px',
background: style.color + '20', color: style.color, fontWeight: 600,
whiteSpace: 'nowrap',
}}>
{style.label}
</span>
<span style={{ fontSize: '12px', color: '#475569', fontVariantNumeric: 'tabular-nums' }}>
{allocated > 0 ? allocated.toFixed(1) : '0'} / 9.0 credits
</span>
</div>
{contributions.length > 0 && (
<div style={{ marginTop: '4px', borderTop: '1px solid #f1f5f9', paddingTop: '6px' }}>
<div style={{ fontSize: '11px', color: '#94a3b8', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.4px', marginBottom: '4px' }}>
Contributing courses
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '2px' }}>
{contributions.map((c, i) => (
<div key={i} style={{ display: 'flex', justifyContent: 'space-between', fontSize: '12px', color: '#475569' }}>
<span style={{ paddingRight: '8px' }}>{c.courseName}</span>
<span style={{ fontWeight: 600, fontVariantNumeric: 'tabular-nums' }}>{c.credits.toFixed(1)}</span>
</div>
))}
</div>
</div>
)}
</div>
);
}
+3 -2
View File
@@ -19,6 +19,7 @@ interface TopPlansProps {
progress: { iterations: number; iterationsTotal: number } | null;
pinnedCourses: Record<string, string | null>;
ranking: string[];
showAnimatedBar?: boolean;
onAdopt: (assignments: Record<string, string>) => void;
onPin: (setId: string, courseId: string) => void;
onUnpin: (setId: string) => void;
@@ -35,7 +36,7 @@ function formatScore(n: number): string {
return `${k.toFixed(k >= 100 ? 0 : 1)}k`;
}
export function TopPlans({ plans, partial, loading, progress, pinnedCourses, ranking, onAdopt, onPin, onUnpin }: TopPlansProps) {
export function TopPlans({ plans, partial, loading, progress, pinnedCourses, ranking, showAnimatedBar = true, onAdopt, onPin, onUnpin }: TopPlansProps) {
const rankWeight = useMemo(() => makePriorityRankWeight(ranking), [ranking]);
const visible = plans.filter((p) => p.achievedSpecs.length > 0);
@@ -67,7 +68,7 @@ export function TopPlans({ plans, partial, loading, progress, pinnedCourses, ran
</span>
)}
</div>
{loading && progress && (
{showAnimatedBar && loading && progress && (
<div style={{ marginBottom: '8px' }}>
<div style={{
position: 'relative', height: '6px', background: '#e5e7eb',
+1 -1
View File
@@ -6,7 +6,7 @@ import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
define: {
__APP_VERSION__: JSON.stringify('1.3.3'),
__APP_VERSION__: JSON.stringify('1.4.0'),
__APP_VERSION_DATE__: JSON.stringify('2026-05-09'),
},
server: {