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:
@@ -1,5 +1,17 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v1.4.0 — 2026-05-09
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
|
||||||
|
- **Desktop layout redesigned** — specializations move from a 340px left rail into a horizontal drag-to-rank chip strip at the top of the page (left = highest priority). The previous right pane splits into two side-by-side columns: Top Plans on the left, Schedule on the right. Each column scrolls independently so the schedule no longer gets pushed below the fold when plans expand.
|
||||||
|
- **Search progress hoisted to a global strip** — the animated progress bar moves out of Top Plans into a thin strip directly below the spec strip. It stays visible regardless of which column you're scrolling. The static "Search complete · N explored" / "Search incomplete · cap hit at N" text remains inline with the Top Plans header.
|
||||||
|
- **Specialization chips with hover popover** — each chip shows rank, full specialization name (line-clamped to 2 lines), status indicator, and a micro credit bar; status is encoded by background color. Hovering (or tapping on touch) opens a popover with the full name, status word, allocated/threshold credits, and — for achieved specializations — the contributing-courses breakdown. The strip scrolls horizontally when the 15 chips don't fit in the available width.
|
||||||
|
- **Horizontal drag-to-reorder on desktop** — switched the spec sort strategy from `verticalListSortingStrategy` to `horizontalListSortingStrategy`. Mobile keeps vertical drag.
|
||||||
|
- **Schedule blocks render horizontal course buttons on desktop** — each non-pinned elective set lays its course choices out as a flex row of equal-width buttons (3–5 per set) instead of stacked rows. Each button shows the info icon (top-left), the recommended star (top-right when applicable), the course name with line-clamp, and a row of spec ceiling tags at the bottom. Cancelled / already-selected / per-course searching states preserve their semantics in the new layout.
|
||||||
|
- **Compact pinned-set rendering** — when a course is pinned, the elective-set card collapses to a single line: `Set Name: Course Name [Clear]`. The previous separate-row pinned-view block is gone, freeing vertical space.
|
||||||
|
- **Mobile (≤640px) layout unchanged** — vertical specialization list, stacked Top Plans + Schedule sections, in-line progress bar, MobileStatusBanner and MobileCourseBanner all behave as before.
|
||||||
|
|
||||||
## v1.3.3 — 2026-05-09
|
## v1.3.3 — 2026-05-09
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
|
|||||||
+118
-60
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo, useRef, useState, useEffect, useCallback } from 'react';
|
import { useMemo, useRef, useState } from 'react';
|
||||||
import { useAppState } from './state/appState';
|
import { useAppState } from './state/appState';
|
||||||
import { useMediaQuery } from './hooks/useMediaQuery';
|
import { useMediaQuery } from './hooks/useMediaQuery';
|
||||||
import { SpecializationRanking } from './components/SpecializationRanking';
|
import { SpecializationRanking } from './components/SpecializationRanking';
|
||||||
@@ -6,11 +6,12 @@ import { ModeToggle } from './components/ModeToggle';
|
|||||||
import { CourseSelection } from './components/CourseSelection';
|
import { CourseSelection } from './components/CourseSelection';
|
||||||
import { CreditLegend } from './components/CreditLegend';
|
import { CreditLegend } from './components/CreditLegend';
|
||||||
import { TopPlans } from './components/TopPlans';
|
import { TopPlans } from './components/TopPlans';
|
||||||
|
import { SearchProgressStrip } from './components/SearchProgressStrip';
|
||||||
import { ModeComparison } from './components/Notifications';
|
import { ModeComparison } from './components/Notifications';
|
||||||
import { MobileStatusBanner } from './components/MobileStatusBanner';
|
|
||||||
import { MobileCourseBanner } from './components/MobileCourseBanner';
|
|
||||||
import { optimize } from './solver/optimizer';
|
import { optimize } from './solver/optimizer';
|
||||||
|
|
||||||
|
type MobileTab = 'specs' | 'plans' | 'courses';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const {
|
const {
|
||||||
state,
|
state,
|
||||||
@@ -43,43 +44,9 @@ function App() {
|
|||||||
|
|
||||||
const isMobile = breakpoint === 'mobile';
|
const isMobile = breakpoint === 'mobile';
|
||||||
|
|
||||||
|
const [mobileTab, setMobileTab] = useState<MobileTab>('specs');
|
||||||
const specSectionRef = useRef<HTMLDivElement>(null);
|
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 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 = {
|
const containerStyle: React.CSSProperties = {
|
||||||
maxWidth: '1200px',
|
maxWidth: '1200px',
|
||||||
@@ -89,27 +56,9 @@ function App() {
|
|||||||
...(isMobile ? {} : { height: '100vh', display: 'flex', flexDirection: 'column', overflow: 'hidden' }),
|
...(isMobile ? {} : { height: '100vh', display: 'flex', flexDirection: 'column', overflow: 'hidden' }),
|
||||||
};
|
};
|
||||||
|
|
||||||
const panelStyle: React.CSSProperties = isMobile
|
if (isMobile) {
|
||||||
? { display: 'flex', flexDirection: 'column', gap: '20px' }
|
|
||||||
: { display: 'grid', gridTemplateColumns: '340px 1fr', gap: '24px', alignItems: 'stretch', flex: 1, minHeight: 0 };
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={containerStyle}>
|
<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' }}>
|
<h1 style={{ fontSize: '20px', marginBottom: '2px', color: '#111' }}>
|
||||||
EMBA Specialization Solver
|
EMBA Specialization Solver
|
||||||
</h1>
|
</h1>
|
||||||
@@ -123,8 +72,44 @@ function App() {
|
|||||||
altModeName={altMode === 'maximize-count' ? 'Maximize Count' : 'Priority Order'}
|
altModeName={altMode === 'maximize-count' ? 'Maximize Count' : 'Priority Order'}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div style={panelStyle}>
|
<SearchProgressStrip loading={treeLoading} progress={searchProgress} />
|
||||||
<div ref={specSectionRef} style={isMobile ? {} : { overflowY: 'auto', minHeight: 0 }}>
|
|
||||||
|
<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 />
|
<CreditLegend />
|
||||||
<SpecializationRanking
|
<SpecializationRanking
|
||||||
ranking={state.ranking}
|
ranking={state.ranking}
|
||||||
@@ -132,7 +117,9 @@ function App() {
|
|||||||
onReorder={reorder}
|
onReorder={reorder}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div ref={courseSectionRef} style={isMobile ? {} : { overflowY: 'auto', minHeight: 0 }}>
|
)}
|
||||||
|
{mobileTab === 'plans' && (
|
||||||
|
<div role="tabpanel">
|
||||||
<TopPlans
|
<TopPlans
|
||||||
plans={topPlans}
|
plans={topPlans}
|
||||||
partial={topPlansPartial}
|
partial={topPlansPartial}
|
||||||
@@ -140,10 +127,81 @@ function App() {
|
|||||||
progress={searchProgress}
|
progress={searchProgress}
|
||||||
pinnedCourses={state.pinnedCourses}
|
pinnedCourses={state.pinnedCourses}
|
||||||
ranking={state.ranking}
|
ranking={state.ranking}
|
||||||
|
showAnimatedBar={false}
|
||||||
onAdopt={adoptPlan}
|
onAdopt={adoptPlan}
|
||||||
onPin={pinCourse}
|
onPin={pinCourse}
|
||||||
onUnpin={unpinCourse}
|
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}>
|
||||||
|
<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'}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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}
|
||||||
|
loading={treeLoading}
|
||||||
|
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
|
<CourseSelection
|
||||||
pinnedCourses={state.pinnedCourses}
|
pinnedCourses={state.pinnedCourses}
|
||||||
treeResults={treeResults}
|
treeResults={treeResults}
|
||||||
|
|||||||
@@ -216,6 +216,7 @@ function ElectiveSet({
|
|||||||
scorer,
|
scorer,
|
||||||
rankWeight,
|
rankWeight,
|
||||||
mode,
|
mode,
|
||||||
|
isMobile,
|
||||||
onPin,
|
onPin,
|
||||||
onUnpin,
|
onUnpin,
|
||||||
openPopoverId,
|
openPopoverId,
|
||||||
@@ -233,6 +234,7 @@ function ElectiveSet({
|
|||||||
scorer: (specs: string[]) => number;
|
scorer: (specs: string[]) => number;
|
||||||
rankWeight: (specs: string[]) => number;
|
rankWeight: (specs: string[]) => number;
|
||||||
mode: OptimizationMode;
|
mode: OptimizationMode;
|
||||||
|
isMobile: boolean;
|
||||||
onPin: (courseId: string) => void;
|
onPin: (courseId: string) => void;
|
||||||
onUnpin: () => void;
|
onUnpin: () => void;
|
||||||
openPopoverId: string | null;
|
openPopoverId: string | null;
|
||||||
@@ -282,15 +284,32 @@ function ElectiveSet({
|
|||||||
border: isPinned ? '1px solid #3b82f6' : '1px solid #ccc',
|
border: isPinned ? '1px solid #3b82f6' : '1px solid #ccc',
|
||||||
borderStyle: isPinned ? 'solid' : 'dashed',
|
borderStyle: isPinned ? 'solid' : 'dashed',
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
padding: '12px',
|
padding: isPinned ? '8px 12px' : '12px',
|
||||||
marginBottom: '8px',
|
marginBottom: '8px',
|
||||||
background: isPinned ? '#eff6ff' : '#fafafa',
|
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' }}>
|
<div style={{
|
||||||
<h4 style={{ fontSize: '13px', margin: 0, color: '#444', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||||
<span>{setName}</span>
|
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 && (
|
{!isPinned && setSearching && (
|
||||||
<span style={{
|
<span style={{
|
||||||
display: 'inline-block', width: '10px', height: '10px', borderRadius: '50%',
|
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>
|
<span style={{ fontSize: '11px', color: '#d97706', fontWeight: 400 }}>high impact</span>
|
||||||
)}
|
)}
|
||||||
</h4>
|
</h4>
|
||||||
{!isPinned && (
|
{!isPinned && isMobile && (
|
||||||
<span style={{
|
<span style={{
|
||||||
fontSize: '10px', color: '#94a3b8', fontWeight: 500, letterSpacing: '0.3px',
|
fontSize: '10px', color: '#94a3b8', fontWeight: 500, letterSpacing: '0.3px',
|
||||||
textTransform: 'uppercase',
|
textTransform: 'uppercase',
|
||||||
|
flexShrink: 0,
|
||||||
}}>
|
}}>
|
||||||
top outcome if picked ↓
|
top outcome if picked ↓
|
||||||
</span>
|
</span>
|
||||||
@@ -317,23 +337,13 @@ function ElectiveSet({
|
|||||||
fontSize: '11px', border: '1px solid #bfdbfe', background: '#eff6ff',
|
fontSize: '11px', border: '1px solid #bfdbfe', background: '#eff6ff',
|
||||||
color: '#2563eb', cursor: 'pointer', padding: '3px 10px',
|
color: '#2563eb', cursor: 'pointer', padding: '3px 10px',
|
||||||
borderRadius: '4px', fontWeight: 500,
|
borderRadius: '4px', fontWeight: 500,
|
||||||
|
flexShrink: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Clear
|
Clear
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 */}
|
{/* Course list view */}
|
||||||
<div style={{
|
<div style={{
|
||||||
maxHeight: isPinned ? '0' : '500px',
|
maxHeight: isPinned ? '0' : '500px',
|
||||||
@@ -342,7 +352,12 @@ function ElectiveSet({
|
|||||||
pointerEvents: isPinned ? 'none' : 'auto',
|
pointerEvents: isPinned ? 'none' : 'auto',
|
||||||
transition: 'max-height 250ms ease-out, opacity 200ms',
|
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) => {
|
{courses.map((course) => {
|
||||||
const isCancelled = !!course.cancelled;
|
const isCancelled = !!course.cancelled;
|
||||||
const isDisabled = disabledCourseIds.has(course.id);
|
const isDisabled = disabledCourseIds.has(course.id);
|
||||||
@@ -353,6 +368,86 @@ function ElectiveSet({
|
|||||||
const cellSearching = !!ceiling && !ceiling.evaluated;
|
const cellSearching = !!ceiling && !ceiling.evaluated;
|
||||||
const isRecommended = recommendedCourseId === course.id;
|
const isRecommended = recommendedCourseId === course.id;
|
||||||
const hasInfo = !!COURSE_DESCRIPTIONS[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 (
|
return (
|
||||||
<button
|
<button
|
||||||
key={course.id}
|
key={course.id}
|
||||||
@@ -403,83 +498,9 @@ function ElectiveSet({
|
|||||||
<span>Recommended</span>
|
<span>Recommended</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{!isUnavailable && hasInfo && (
|
{infoIcon}
|
||||||
<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>
|
||||||
)}
|
{ceilingTags}
|
||||||
</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>
|
</div>
|
||||||
{reqFor && !isUnavailable && (
|
{reqFor && !isUnavailable && (
|
||||||
<span style={{ fontSize: '11px', color: '#92400e', marginTop: '2px' }}>
|
<span style={{ fontSize: '11px', color: '#92400e', marginTop: '2px' }}>
|
||||||
@@ -488,6 +509,87 @@ function ElectiveSet({
|
|||||||
)}
|
)}
|
||||||
</button>
|
</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 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: '12px',
|
||||||
|
color: isUnavailable ? '#bbb' : '#333',
|
||||||
|
pointerEvents: isUnavailable ? 'none' : 'auto',
|
||||||
|
font: 'inherit',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
</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: '10px', color: '#92400e', lineHeight: 1.3 }}>
|
||||||
|
Req. for {reqFor.join(', ')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -506,6 +608,8 @@ export function CourseSelection({ pinnedCourses, treeResults, treeLoading, disab
|
|||||||
const rankWeight = useMemo(() => makePriorityRankWeight(ranking), [ranking]);
|
const rankWeight = useMemo(() => makePriorityRankWeight(ranking), [ranking]);
|
||||||
const terms: Term[] = ['Spring', 'Summer', 'Fall'];
|
const terms: Term[] = ['Spring', 'Summer', 'Fall'];
|
||||||
const hasPinned = Object.keys(pinnedCourses).length > 0;
|
const hasPinned = Object.keys(pinnedCourses).length > 0;
|
||||||
|
const breakpoint = useMediaQuery();
|
||||||
|
const isMobile = breakpoint === 'mobile';
|
||||||
|
|
||||||
// Index tree results by setId for O(1) lookup
|
// Index tree results by setId for O(1) lookup
|
||||||
const treeBySet = new Map(treeResults.map((a) => [a.setId, a]));
|
const treeBySet = new Map(treeResults.map((a) => [a.setId, a]));
|
||||||
@@ -586,6 +690,7 @@ export function CourseSelection({ pinnedCourses, treeResults, treeLoading, disab
|
|||||||
scorer={scorer}
|
scorer={scorer}
|
||||||
rankWeight={rankWeight}
|
rankWeight={rankWeight}
|
||||||
mode={mode}
|
mode={mode}
|
||||||
|
isMobile={isMobile}
|
||||||
onPin={(courseId) => onPin(set.id, courseId)}
|
onPin={(courseId) => onPin(set.id, courseId)}
|
||||||
onUnpin={() => onUnpin(set.id)}
|
onUnpin={() => onUnpin(set.id)}
|
||||||
openPopoverId={popover?.courseId ?? null}
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
DndContext,
|
DndContext,
|
||||||
closestCenter,
|
closestCenter,
|
||||||
@@ -14,11 +14,14 @@ import {
|
|||||||
sortableKeyboardCoordinates,
|
sortableKeyboardCoordinates,
|
||||||
useSortable,
|
useSortable,
|
||||||
verticalListSortingStrategy,
|
verticalListSortingStrategy,
|
||||||
|
rectSortingStrategy,
|
||||||
arrayMove,
|
arrayMove,
|
||||||
} from '@dnd-kit/sortable';
|
} from '@dnd-kit/sortable';
|
||||||
import { CSS } from '@dnd-kit/utilities';
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
import { SPECIALIZATIONS } from '../data/specializations';
|
import { SPECIALIZATIONS } from '../data/specializations';
|
||||||
import { courseById } from '../data/lookups';
|
import { courseById } from '../data/lookups';
|
||||||
|
import { specColor } from '../data/specColors';
|
||||||
|
import { useMediaQuery } from '../hooks/useMediaQuery';
|
||||||
import type { SpecStatus, AllocationResult } from '../data/types';
|
import type { SpecStatus, AllocationResult } from '../data/types';
|
||||||
|
|
||||||
export const STATUS_STYLES: Record<SpecStatus, { bg: string; color: string; label: string }> = {
|
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 {
|
interface SpecializationRankingProps {
|
||||||
ranking: string[];
|
ranking: string[];
|
||||||
result: AllocationResult;
|
result: AllocationResult;
|
||||||
|
headerSlot?: React.ReactNode;
|
||||||
onReorder: (ranking: string[]) => void;
|
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 [expanded, setExpanded] = useState<Set<string>>(() => new Set(result.achieved));
|
||||||
const prevAchievedRef = useRef(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 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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -305,3 +330,357 @@ export function SpecializationRanking({ ranking, result, onReorder }: Specializa
|
|||||||
</div>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ interface TopPlansProps {
|
|||||||
progress: { iterations: number; iterationsTotal: number } | null;
|
progress: { iterations: number; iterationsTotal: number } | null;
|
||||||
pinnedCourses: Record<string, string | null>;
|
pinnedCourses: Record<string, string | null>;
|
||||||
ranking: string[];
|
ranking: string[];
|
||||||
|
showAnimatedBar?: boolean;
|
||||||
onAdopt: (assignments: Record<string, string>) => void;
|
onAdopt: (assignments: Record<string, string>) => void;
|
||||||
onPin: (setId: string, courseId: string) => void;
|
onPin: (setId: string, courseId: string) => void;
|
||||||
onUnpin: (setId: string) => void;
|
onUnpin: (setId: string) => void;
|
||||||
@@ -35,7 +36,7 @@ function formatScore(n: number): string {
|
|||||||
return `${k.toFixed(k >= 100 ? 0 : 1)}k`;
|
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 rankWeight = useMemo(() => makePriorityRankWeight(ranking), [ranking]);
|
||||||
const visible = plans.filter((p) => p.achievedSpecs.length > 0);
|
const visible = plans.filter((p) => p.achievedSpecs.length > 0);
|
||||||
|
|
||||||
@@ -67,7 +68,7 @@ export function TopPlans({ plans, partial, loading, progress, pinnedCourses, ran
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{loading && progress && (
|
{showAnimatedBar && loading && progress && (
|
||||||
<div style={{ marginBottom: '8px' }}>
|
<div style={{ marginBottom: '8px' }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
position: 'relative', height: '6px', background: '#e5e7eb',
|
position: 'relative', height: '6px', background: '#e5e7eb',
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@ import react from '@vitejs/plugin-react'
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
define: {
|
define: {
|
||||||
__APP_VERSION__: JSON.stringify('1.3.3'),
|
__APP_VERSION__: JSON.stringify('1.4.0'),
|
||||||
__APP_VERSION_DATE__: JSON.stringify('2026-05-09'),
|
__APP_VERSION_DATE__: JSON.stringify('2026-05-09'),
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-05-09
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
The desktop layout in `App.tsx` today is a 2-column grid: a 340px specialization rail on the left and a 1fr right pane on the right that stacks `TopPlans` over `CourseSelection` and scrolls together. The right pane must accommodate three concerns at once — search progress, plan list, and a 12-set schedule — and ranking the specs ties up significant width that the schedule could use.
|
||||||
|
|
||||||
|
Within `CourseSelection`, each elective set renders its courses as full-width vertically stacked rows. On desktop the right pane is ~836px wide, so each row uses lots of horizontal space for one course; the schedule extends well past the viewport.
|
||||||
|
|
||||||
|
The search progress bar lives inside `TopPlans`, so when a user is scrolled down looking at the schedule it disappears.
|
||||||
|
|
||||||
|
This change reflows the desktop layout to:
|
||||||
|
1. A horizontal spec strip at the top (drag L→R to rank).
|
||||||
|
2. A hoisted, full-width progress bar.
|
||||||
|
3. A 2-column workspace below: Top Plans (left) | Schedule (right), each with independent scroll.
|
||||||
|
4. Schedule blocks render their course choices as a horizontal flex row of buttons rather than stacked rows.
|
||||||
|
|
||||||
|
Mobile (≤768px in the project's `useMediaQuery` hook) keeps its current vertical arrangement — this is a desktop-only redesign.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
- Eliminate the "Top Plans pushes Schedule below the fold" problem by giving each its own column with independent scroll.
|
||||||
|
- Free vertical space in the schedule by laying course choices out horizontally per elective set.
|
||||||
|
- Keep search progress visible from any scroll position by hoisting the progress bar.
|
||||||
|
- Preserve all existing semantics: pinning, recommended star, ceiling tags, info popover, cancelled/already-selected states, per-course searching, mode comparison banner, achievement count.
|
||||||
|
- Preserve mobile UX exactly — no regression in the mobile experience.
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
- No solver, worker, or data-layer changes. The decision-tree pipeline and leaf cache are untouched.
|
||||||
|
- No new features (e.g., new filters, new modes, search of plans). Pure UI reflow.
|
||||||
|
- No tablet-specific rework. Tablet is treated the same as desktop in `useMediaQuery` (returns `desktop` for ≥768px), so it inherits the new layout. No separate intermediate layout.
|
||||||
|
- The horizontal chip strip is desktop-only; mobile is not getting horizontal chips.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### D1: Specs go to a horizontal strip with hover popover detail
|
||||||
|
|
||||||
|
Compact 15-chip strip in a single row, each ~70px. Each chip shows rank number, 3-letter abbreviation (already in `SPECIALIZATIONS`), and a micro credit bar. Background color encodes status using the existing `STATUS_STYLES` palette.
|
||||||
|
|
||||||
|
Detail (full name, status word, allocated/threshold numeric, contributing-courses breakdown) lives in a hover popover anchored to the chip. Touch users on hybrid devices get the same popover via tap-toggle (`onClick`).
|
||||||
|
|
||||||
|
**Why not vertical-with-collapsed-detail?** Considered, but the user's mental model is "left = highest priority" — horizontal flow matches reading direction and frees the entire left rail for the workspace below. A wrap-to-2-rows variant was rejected because it breaks the left-to-right priority continuity.
|
||||||
|
|
||||||
|
**Why not include the full row content (name, numbers, status word) in each chip?** 70px is too narrow. Hover popover gets the detail without crowding the strip.
|
||||||
|
|
||||||
|
### D2: Drag strategy switches to horizontal
|
||||||
|
|
||||||
|
`@dnd-kit/sortable` is already a dependency. Switch the `SortableContext` strategy from `verticalListSortingStrategy` to `horizontalListSortingStrategy` on desktop. Mobile keeps `verticalListSortingStrategy`. We branch on `isMobile` from the existing `useMediaQuery` hook inside `SpecializationRanking`.
|
||||||
|
|
||||||
|
### D3: Progress bar hoisted into a global strip
|
||||||
|
|
||||||
|
Today `TopPlans` renders both the progress bar and the "search complete / cap hit" status text. Extract a small `SearchProgressStrip` component (or inline JSX in `App.tsx`) that consumes the same `searchProgress` and `treeLoading` state from `useAppState`. `TopPlans` keeps the static "search complete" text in its header (it pertains to the plan list specifically), but the animated progress bar moves out.
|
||||||
|
|
||||||
|
On mobile the progress bar stays inside `TopPlans` to avoid stealing precious vertical space at the top.
|
||||||
|
|
||||||
|
### D4: 2-column workspace with independent scroll
|
||||||
|
|
||||||
|
```
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 24px;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0; /* required so children can shrink and scroll */
|
||||||
|
|
||||||
|
each child: overflowY: auto; min-height: 0;
|
||||||
|
```
|
||||||
|
|
||||||
|
This is a small reuse of the existing pattern in `App.tsx` (the current right pane already uses `overflowY: auto; min-height: 0`).
|
||||||
|
|
||||||
|
**Why 1fr / 1fr (50/50)?** Plans rows render 12 mini-set buttons that benefit from width; schedule blocks render 3-5 buttons per row that also benefit from width. Neither dominates. Asymmetric splits (e.g., 40/60) showed plan mini-cards wrapping awkwardly in the 40 case.
|
||||||
|
|
||||||
|
### D5: Schedule course buttons go horizontal (flex stretch)
|
||||||
|
|
||||||
|
Each non-pinned elective set replaces its column-of-rows with:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<div style={{ display: 'flex', gap: 4 }}>
|
||||||
|
{courses.map(c => <CourseButton style={{ flex: '1 1 0' }} ... />)}
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
Equal width per button within a set; widths vary set-to-set (3-button sets have wider buttons than 5-button sets). No flex-wrap — tested width math (109px minimum for 5-button case in a 588px column) is acceptable for the 2-line line-clamp.
|
||||||
|
|
||||||
|
**Per-button anatomy:**
|
||||||
|
- Top row: info icon (existing `i` button, top-left), recommended star (top-right when applicable).
|
||||||
|
- Middle: course name with `WebkitLineClamp: 2-3`. Title attribute carries full name for hover.
|
||||||
|
- Footer: spec-tag row (existing `SpecTag` component), allowed to wrap if a course has 4+ specs.
|
||||||
|
- Cancelled/already-selected: gray background, strikethrough name, small footer text "Cancelled" or "Already selected".
|
||||||
|
- Searching: pulsing skeleton bar where spec tags would render.
|
||||||
|
- Required-for note: small amber footer line below the spec tags.
|
||||||
|
|
||||||
|
**Pinned sets stay as today:** single line "Course Name + Clear button". Only unpinned sets get the new horizontal layout.
|
||||||
|
|
||||||
|
**Why not a CSS grid with auto-fit?** Considered `grid-template-columns: repeat(auto-fit, minmax(110px, 1fr))` which would wrap automatically. Rejected because it can produce inconsistent per-set widths that don't align with the set's actual course count, and because the courses-per-set is bounded (3–5) so flex with no wrap is simpler.
|
||||||
|
|
||||||
|
### D6: Term headings (Spring/Summer/Fall) remain as section dividers
|
||||||
|
|
||||||
|
User confirmed. They're cheap (~30px each) and orient the user temporally. No change needed.
|
||||||
|
|
||||||
|
### D7: CreditLegend collapses into the spec strip header
|
||||||
|
|
||||||
|
Today `CreditLegend` is its own block in the right pane. It would be in the way of the new column split. Rendered as a small `[▸ legend]` toggle button in the spec strip's right-aligned header area; clicking expands an inline panel below the strip with the same content the legend already provides. The component itself stays largely as-is — it's already a self-contained collapsible.
|
||||||
|
|
||||||
|
### D8: Hover popover positioning for spec chips
|
||||||
|
|
||||||
|
Anchor to the chip's bounding rect. Reuse the smart-flip logic already in `CourseInfoPopover` (`spaceBelow` / `spaceAbove`, `placeAbove`) for vertical fit. Horizontally, clamp `left` to `Math.min(rect.left, window.innerWidth - popoverWidth - 8)` so chips near the right edge don't clip the popover off-screen.
|
||||||
|
|
||||||
|
Use a 150ms close delay (same pattern as the course info popover) so the user can move the cursor from chip → popover without it disappearing.
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- **Risk: 70px chip width doesn't render legibly at narrower desktop viewports (e.g., 1024px usable).** → Mitigation: the layout's `maxWidth: 1200px` is centered, so a 1024px viewport gives a smaller container. We allow horizontal scrolling on the chip strip if the strip content exceeds container width (`overflowX: auto`) as a safety valve. Cleaner option: enforce a min container width (e.g., 1200px → fall back to mobile-style vertical layout below it). Decision: take the `overflowX: auto` safety valve; the project uses a desktop breakpoint of `≥768px` and below ~1100px the chip strip will get a horizontal scrollbar on the strip element only.
|
||||||
|
- **Risk: Course buttons with 4+ spec tags overflow at narrow column widths.** → Mitigation: spec tag row uses `flex-wrap: wrap`; button heights flex-stretch so the row of buttons stays vertically aligned even when one button wraps to a 3-line spec-tag area.
|
||||||
|
- **Risk: Touch device on the desktop breakpoint (e.g., iPad in landscape, when `useMediaQuery` may treat it as desktop) has no real hover.** → Mitigation: tap on a chip toggles the popover open/closed; tap outside closes. Same pattern as `CourseInfoPopover`. Use `onClick` for parity with hover on desktop.
|
||||||
|
- **Risk: Drag-and-drop horizontal feels different from vertical and may need tweaked activation distance.** → Mitigation: keep the existing `PointerSensor` activation distance (5px). `horizontalListSortingStrategy` works out of the box for left-right reorders. Test with keyboard-driven reorder (arrow keys via `KeyboardSensor`).
|
||||||
|
- **Risk: Hoisted progress bar feels disconnected from "Top Plans" since the static "Search complete · X explored" text remains in the plan list header.** → Mitigation: keep the static text in `TopPlans` (it's a state summary), move only the animated bar. The user mental model is "progress = global; result counts per panel = local." Acceptable.
|
||||||
|
- **Risk: Mobile regression.** → Mitigation: `App.tsx` already branches on `isMobile`; we keep the mobile branch's JSX identical and only restructure the desktop branch. Spec strip, course button row, and column split are all gated behind `!isMobile`. The existing `MobileStatusBanner` and `MobileCourseBanner` continue to anchor to `specSectionRef` and `courseSectionRef` — those refs need to point to mobile-only sections, which we keep.
|
||||||
|
- **Trade-off: Lost at-a-glance numeric credit display in the spec strip.** Users wanting "5.5 / 9.0" must hover. Acceptable given the strip's information density goal.
|
||||||
|
- **Trade-off: Lost at-a-glance status text label ("Achieved", "Achievable", etc.) in the spec strip.** Background color carries this. Status word still visible in the hover popover. Color-only meets design goal but accessibility could improve later with optional text dot/glyph; not in scope here.
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
This is a UI-only change. No data migration, no API contract change, no breaking persistence changes (`localStorage` keys for ranking and pinned courses untouched).
|
||||||
|
|
||||||
|
Rollout: ship in v1.4.0 (minor bump per CHANGELOG convention). Single deployment; no flag gating needed since the behavior is purely visual.
|
||||||
|
|
||||||
|
If the redesign needs to be reverted, revert the App.tsx, SpecializationRanking.tsx, TopPlans.tsx, CourseSelection.tsx changes and restore the version bump. State and solver code are untouched.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- None blocking implementation. Width edge cases (very narrow desktop viewports, very wide popover content) are handled by `overflowX: auto` + popover smart-flip.
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
On desktop the right pane stacks Top Plans on top of the Schedule, so when the search produces several plans they push the 12 elective-set blocks below the fold. Users lose their spatial sense of the schedule while exploring plans, and the schedule blocks themselves stack each course as a full-width row, which is wasteful of horizontal space and makes the page feel taller than it needs to be.
|
||||||
|
|
||||||
|
The specialization ranking sits in a 340px left rail and forces the rest of the page into a narrower right pane. Moving it to a horizontal strip at the top frees the full container width for the workspace below and matches the natural "reading order" of priority (left = highest).
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- Desktop layout reflows from 2-column (specs | right pane) to a top spec strip + global progress bar + 2-column workspace (Top Plans | Schedule), each column scrolling independently.
|
||||||
|
- Specialization ranking becomes a single horizontal row of 15 compact chips (~70px each). Each chip shows rank, abbreviation, and a micro credit bar; full name, status word, allocated/threshold, and contributing-course breakdown move into a hover popover. Drag-to-reorder switches from `verticalListSortingStrategy` to `horizontalListSortingStrategy`.
|
||||||
|
- The search progress bar is hoisted out of `TopPlans` into a thin global strip below the spec band so it stays visible from both columns.
|
||||||
|
- Each elective-set block in the Schedule column renders its courses as a horizontal flex row of equal-width buttons (3–5 per set, depending on data) instead of stacked rows. Each button shows: info icon (top-left), recommended star (top-right when applicable), course name with line-clamp, and a bottom row of spec ceiling tags. Cancelled / already-selected / per-course-searching states keep their semantics, restyled into the button form.
|
||||||
|
- SPRING / SUMMER / FALL term headings remain as section dividers in the Schedule column.
|
||||||
|
- `CreditLegend` collapses into a `[▸ legend]` toggle in the spec strip header (it currently sits in the right pane, where it would be in the way under the new layout).
|
||||||
|
- Mobile (≤768px) layout is unchanged — vertical specialization list, stacked Top Plans + Schedule. The redesign is desktop-only.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
_None._
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
- `responsive-layout`: redefines the desktop arrangement as a top spec strip + hoisted progress bar + 2-column workspace (Top Plans | Schedule) with independent scrolling. Mobile and tablet behavior unchanged.
|
||||||
|
- `unified-specialization-panel`: adds a desktop-only horizontal-strip rendering with compact chips, hover popover for detail, and horizontal drag-to-reorder. Mobile keeps the existing vertical list.
|
||||||
|
- `unified-course-panel`: redesigns each non-pinned elective-set block on desktop to render courses as a horizontal flex row of buttons. Pinned-set rendering and mobile rendering unchanged.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- `app/src/App.tsx` — restructure desktop layout: top spec strip, progress strip, 2-column grid for plans/schedule, each scrolling independently. Mobile branch untouched.
|
||||||
|
- `app/src/components/SpecializationRanking.tsx` — add desktop-strip variant: compact chip rendering, hover-popover detail with full name + status + numeric credits + allocation breakdown, switch dnd-kit strategy to `horizontalListSortingStrategy` on desktop. Keep vertical rendering on mobile.
|
||||||
|
- `app/src/components/TopPlans.tsx` — remove the inline progress bar and the "search complete / cap hit" status text; render only the plan list. The progress bar moves to a new component.
|
||||||
|
- New `app/src/components/SearchProgressStrip.tsx` (or inline in `App.tsx`) — renders the hoisted progress bar with the same `progress` and `loading` inputs `TopPlans` used today.
|
||||||
|
- `app/src/components/CourseSelection.tsx` — restyle the unpinned-set rendering: replace the column-of-rows with a flex row of buttons. Buttons absorb info-icon + recommended badge + course name + spec tag row + cancelled/selected/searching states. Pinned-set view unchanged.
|
||||||
|
- `app/src/components/CreditLegend.tsx` — consumed via the spec strip header toggle; component itself can stay as-is or be inlined.
|
||||||
|
- No solver, worker, or data-file changes.
|
||||||
|
- `app/vite.config.ts` — bump to `1.4.0`.
|
||||||
|
- `CHANGELOG.md` — release entry.
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Mobile-first responsive layout
|
||||||
|
The app SHALL adapt its layout to two viewport classes: mobile (<768px) and desktop (≥768px). On mobile, all panels SHALL stack vertically in a single column. On desktop, the layout SHALL use a top-to-bottom arrangement of: header + mode toggle + mode comparison banner, a horizontal specialization strip, a global search-progress strip, and a 2-column workspace below containing Top Plans (left) and Schedule (right). The two workspace columns SHALL scroll independently while the spec strip and progress strip SHALL remain fixed at the top of the workspace area.
|
||||||
|
|
||||||
|
#### Scenario: Mobile viewport
|
||||||
|
- **WHEN** the viewport width is less than 768px
|
||||||
|
- **THEN** the layout SHALL display as a single column with the specialization panel above the Top Plans section above the Schedule section, all full-width
|
||||||
|
|
||||||
|
#### Scenario: Desktop viewport
|
||||||
|
- **WHEN** the viewport width is 768px or greater
|
||||||
|
- **THEN** the layout SHALL display the horizontal specialization strip across the top, a global progress strip directly below it, and a 2-column workspace below the progress strip with Top Plans on the left and Schedule on the right
|
||||||
|
|
||||||
|
#### Scenario: Independent column scroll on desktop
|
||||||
|
- **WHEN** the user is on desktop and scrolls inside the Top Plans column
|
||||||
|
- **THEN** only the Top Plans column SHALL scroll while the Schedule column, the progress strip, and the specialization strip SHALL remain in place
|
||||||
|
|
||||||
|
#### Scenario: Independent column scroll on desktop (Schedule)
|
||||||
|
- **WHEN** the user is on desktop and scrolls inside the Schedule column
|
||||||
|
- **THEN** only the Schedule column SHALL scroll while the Top Plans column, the progress strip, and the specialization strip SHALL remain in place
|
||||||
|
|
||||||
|
### Requirement: Notification banners span full width
|
||||||
|
Mode comparison and mutual exclusion warnings SHALL render as full-width banners above the main panel layout, not inside a specific column.
|
||||||
|
|
||||||
|
#### Scenario: Warning banner placement
|
||||||
|
- **WHEN** a mutual exclusion warning or mode comparison banner is active
|
||||||
|
- **THEN** the banner SHALL appear between the header/mode toggle and the specialization strip (desktop) or the specialization panel (mobile), spanning the full container width
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Global search-progress strip on desktop
|
||||||
|
On desktop, the active search progress bar SHALL render as a thin full-width strip directly below the specialization strip and above the workspace columns. It SHALL remain visible from any scroll position within either workspace column. On mobile, the progress bar SHALL remain inside the Top Plans section as today.
|
||||||
|
|
||||||
|
#### Scenario: Search in progress on desktop
|
||||||
|
- **WHEN** the decision-tree search is running on desktop
|
||||||
|
- **THEN** an animated progress bar SHALL render in a strip between the specialization strip and the workspace columns, showing the iteration count and percent complete
|
||||||
|
|
||||||
|
#### Scenario: Search complete on desktop
|
||||||
|
- **WHEN** the decision-tree search has completed on desktop
|
||||||
|
- **THEN** the progress bar SHALL no longer animate; the static "Search complete · N explored" or "Search incomplete · cap hit at N" status MAY remain visible inline with the Top Plans header
|
||||||
|
|
||||||
|
#### Scenario: Search progress on mobile
|
||||||
|
- **WHEN** the decision-tree search is running on mobile
|
||||||
|
- **THEN** the progress bar SHALL render inside the Top Plans section, not in a global strip
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Desktop horizontal course button arrangement
|
||||||
|
On desktop, each non-pinned elective set in the Schedule column SHALL render its course choices as a horizontal flex row of equal-width buttons (one button per course), with each button stretching to fill its share of the row. Pinned sets SHALL continue to render as today (single-line course name with a Clear button). On mobile, course choices SHALL continue to render as vertically stacked rows.
|
||||||
|
|
||||||
|
#### Scenario: Desktop unpinned set renders horizontal row
|
||||||
|
- **WHEN** the user views an unpinned elective set on desktop
|
||||||
|
- **THEN** the set's course choices SHALL render as a horizontal flex row of equal-width buttons, one button per course
|
||||||
|
|
||||||
|
#### Scenario: Desktop unpinned set with three courses
|
||||||
|
- **WHEN** an unpinned elective set has 3 courses on desktop
|
||||||
|
- **THEN** the row SHALL render 3 buttons stretched to fill the available width
|
||||||
|
|
||||||
|
#### Scenario: Desktop unpinned set with five courses
|
||||||
|
- **WHEN** an unpinned elective set has 5 courses on desktop
|
||||||
|
- **THEN** the row SHALL render 5 buttons stretched to fill the available width, narrower than the 3-course case
|
||||||
|
|
||||||
|
#### Scenario: Pinned set on desktop unchanged
|
||||||
|
- **WHEN** an elective set is pinned on desktop
|
||||||
|
- **THEN** the set SHALL continue to render the pinned course name with a Clear button on a single line, not as a horizontal row
|
||||||
|
|
||||||
|
#### Scenario: Mobile keeps stacked rows
|
||||||
|
- **WHEN** the user views an unpinned elective set on mobile
|
||||||
|
- **THEN** the course choices SHALL continue to render as vertically stacked full-width rows
|
||||||
|
|
||||||
|
### Requirement: Course button anatomy on desktop
|
||||||
|
Each course button in a desktop horizontal row SHALL display: an info icon in the top-left when course description info is available, a "recommended" star indicator in the top-right when this course is the recommended choice for the set, the course name with a multi-line clamp (max 2-3 lines) and the full name available via the title attribute, and a row of spec ceiling tags at the bottom showing the specializations this course could contribute to.
|
||||||
|
|
||||||
|
#### Scenario: Button with all elements
|
||||||
|
- **WHEN** a course on desktop is the recommended choice, has a description, and has spec qualifications
|
||||||
|
- **THEN** the button SHALL show the info icon (top-left), the recommended star (top-right), the course name with line-clamp, and the spec ceiling tags at the bottom
|
||||||
|
|
||||||
|
#### Scenario: Course without info
|
||||||
|
- **WHEN** a course on desktop has no description in `COURSE_DESCRIPTIONS`
|
||||||
|
- **THEN** the button SHALL omit the info icon while keeping all other elements
|
||||||
|
|
||||||
|
#### Scenario: Course not recommended
|
||||||
|
- **WHEN** a course on desktop is not the recommended choice for its set
|
||||||
|
- **THEN** the button SHALL omit the recommended star while keeping all other elements
|
||||||
|
|
||||||
|
#### Scenario: Long course name truncation
|
||||||
|
- **WHEN** a course name exceeds the line-clamp limit
|
||||||
|
- **THEN** the visible text SHALL truncate with an ellipsis and the full name SHALL be available via the title attribute on hover
|
||||||
|
|
||||||
|
#### Scenario: Spec tag overflow within button
|
||||||
|
- **WHEN** a course has more spec tags than fit in a single row inside the button at the current button width
|
||||||
|
- **THEN** the spec tag row SHALL wrap to multiple lines within the button, and the button heights in the set SHALL flex-stretch to remain aligned
|
||||||
|
|
||||||
|
### Requirement: Course button states on desktop
|
||||||
|
Course buttons on desktop SHALL communicate the following states with distinct styling: cancelled (strikethrough name, gray background, "Cancelled" footer text, non-clickable), already-selected-elsewhere (gray background, "Already selected" footer text, non-clickable), per-course searching (skeleton placeholder where spec tags would render), recommended (recommended star + visual emphasis), and required-for-spec (small amber footer note "Required for X" when the course is required for one or more specializations).
|
||||||
|
|
||||||
|
#### Scenario: Cancelled course button
|
||||||
|
- **WHEN** a course in an unpinned set has `cancelled: true`
|
||||||
|
- **THEN** its button SHALL render with strikethrough name, gray background, a "(Cancelled)" footer label, and SHALL not be clickable
|
||||||
|
|
||||||
|
#### Scenario: Already-selected course button
|
||||||
|
- **WHEN** a course is in the disabled-because-pinned-elsewhere set
|
||||||
|
- **THEN** its button SHALL render with gray background, an "(Already selected)" footer label, and SHALL not be clickable
|
||||||
|
|
||||||
|
#### Scenario: Per-course searching state
|
||||||
|
- **WHEN** the decision tree analysis for a course is still in progress (the course's `evaluated` flag is false)
|
||||||
|
- **THEN** the button SHALL render a pulsing skeleton band where the spec ceiling tags would otherwise appear, with all other content unchanged
|
||||||
|
|
||||||
|
#### Scenario: Required-for footer
|
||||||
|
- **WHEN** a course is required for one or more specializations
|
||||||
|
- **THEN** the button SHALL render a small amber "Required for X" footer beneath the spec tag row
|
||||||
|
|
||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Inline decision tree ceiling per course option
|
||||||
|
When decision tree analysis is available for an open elective set, each course option SHALL display its ceiling outcome (spec abbreviations) as small spec tags. On mobile, the spec tags SHALL render on the right side of each course row. On desktop, the spec tags SHALL render in a row along the bottom of each course button.
|
||||||
|
|
||||||
|
#### Scenario: Mobile ceiling data on right side
|
||||||
|
- **WHEN** an open set has completed decision tree analysis on mobile and a course has ceiling specs (BNK, FIN, LCM)
|
||||||
|
- **THEN** the course row SHALL show the course name on the left and tags for BNK, FIN, LCM on the right
|
||||||
|
|
||||||
|
#### Scenario: Desktop ceiling data along bottom
|
||||||
|
- **WHEN** an open set has completed decision tree analysis on desktop and a course has ceiling specs (BNK, FIN, LCM)
|
||||||
|
- **THEN** the course button SHALL show the course name with line-clamp at top and a row of tags for BNK, FIN, LCM along the bottom of the button
|
||||||
|
|
||||||
|
#### Scenario: Ceiling data not yet available
|
||||||
|
- **WHEN** an open set's decision tree analysis is still computing
|
||||||
|
- **THEN** the course buttons (mobile rows or desktop buttons) SHALL render without ceiling tags, and the set header SHALL show a subtle loading indicator
|
||||||
|
|
||||||
|
#### Scenario: Pinned set does not show ceiling
|
||||||
|
- **WHEN** a set has a pinned course selection
|
||||||
|
- **THEN** the set SHALL display the pinned course name without ceiling data on either mobile or desktop (same as current behavior)
|
||||||
|
|
||||||
|
### Requirement: High impact indicator on set header
|
||||||
|
When a set has high impact (variance > 0 in ceiling outcomes), the set header SHALL display a "high impact" indicator. This applies on both mobile and desktop.
|
||||||
|
|
||||||
|
#### Scenario: High impact set on mobile or desktop
|
||||||
|
- **WHEN** an open set's analysis shows impact > 0
|
||||||
|
- **THEN** the set header SHALL display a "high impact" label next to the set name
|
||||||
|
|
||||||
|
### Requirement: No standalone decision tree section
|
||||||
|
The standalone DecisionTree component at the bottom of the results dashboard SHALL be removed. All ceiling data SHALL be displayed inline within the course selection panel — on mobile as right-side row content, on desktop as bottom-of-button content.
|
||||||
|
|
||||||
|
#### Scenario: All tree data inline on mobile
|
||||||
|
- **WHEN** the user views the course selection panel on mobile
|
||||||
|
- **THEN** there SHALL be no separate "Decision Tree" heading or section; all ceiling outcomes appear within their respective elective set cards as right-side row content
|
||||||
|
|
||||||
|
#### Scenario: All tree data inline on desktop
|
||||||
|
- **WHEN** the user views the Schedule column on desktop
|
||||||
|
- **THEN** there SHALL be no separate "Decision Tree" heading or section; all ceiling outcomes appear within their respective elective set cards as bottom-of-button content
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Desktop horizontal specialization strip
|
||||||
|
On desktop, the specialization panel SHALL render as a single-row horizontal strip of compact chips, one chip per specialization, ordered left-to-right by priority rank. Each chip SHALL display the rank number, the specialization's 3-letter abbreviation, and a micro credit progress bar. The chip's background color SHALL encode the specialization's status using the same four-color palette used for vertical rows on mobile (achieved, achievable, missing required, unreachable).
|
||||||
|
|
||||||
|
#### Scenario: Strip rendering on desktop
|
||||||
|
- **WHEN** the user views the app on desktop
|
||||||
|
- **THEN** all specializations SHALL render as a horizontal row of compact chips in priority order (highest priority leftmost)
|
||||||
|
|
||||||
|
#### Scenario: Chip status color
|
||||||
|
- **WHEN** a specialization is in the "achieved" status
|
||||||
|
- **THEN** its chip SHALL render with the achieved-status background color from `STATUS_STYLES`
|
||||||
|
|
||||||
|
#### Scenario: Chip content
|
||||||
|
- **WHEN** a specialization chip renders on desktop
|
||||||
|
- **THEN** the chip SHALL display the rank number, the 3-letter abbreviation (e.g., "FIN"), and a micro credit progress bar reflecting allocated credits versus the 9-credit threshold
|
||||||
|
|
||||||
|
#### Scenario: Strip overflow on narrow desktop viewports
|
||||||
|
- **WHEN** the desktop viewport width is too narrow to fit all chips at their target width
|
||||||
|
- **THEN** the strip SHALL allow horizontal scrolling within itself rather than wrap to multiple rows
|
||||||
|
|
||||||
|
### Requirement: Specialization chip hover popover
|
||||||
|
On desktop, hovering or tapping a specialization chip SHALL open a popover anchored to the chip showing the full specialization name, the status word ("Achieved" / "Achievable" / "Missing Required" / "Unreachable"), the allocated/threshold credits in numeric form (e.g., "5.5 / 9.0"), and — for achieved specializations — the contributing-courses breakdown (each contributing course name and its credit amount). The popover SHALL stay open while the cursor is over either the chip or the popover, and SHALL close when the cursor leaves both.
|
||||||
|
|
||||||
|
#### Scenario: Hover opens popover
|
||||||
|
- **WHEN** the user moves the cursor over a specialization chip on desktop
|
||||||
|
- **THEN** a popover SHALL open anchored to the chip showing the full name, status word, and allocated/threshold credits
|
||||||
|
|
||||||
|
#### Scenario: Popover for achieved specialization
|
||||||
|
- **WHEN** the user hovers a chip whose specialization is achieved
|
||||||
|
- **THEN** the popover SHALL additionally list each contributing course's name and its credit amount
|
||||||
|
|
||||||
|
#### Scenario: Popover for non-achieved specialization
|
||||||
|
- **WHEN** the user hovers a chip whose specialization is not in the achieved status
|
||||||
|
- **THEN** the popover SHALL omit the contributing-courses breakdown but SHALL still show the full name, status word, and allocated/threshold credits
|
||||||
|
|
||||||
|
#### Scenario: Tap toggles popover on touch
|
||||||
|
- **WHEN** the user taps a chip on a touch device using the desktop layout
|
||||||
|
- **THEN** the popover SHALL toggle open or closed; tapping outside the chip and popover SHALL close it
|
||||||
|
|
||||||
|
#### Scenario: Popover stays open while cursor moves to popover
|
||||||
|
- **WHEN** the user hovers a chip to open the popover and then moves the cursor onto the popover
|
||||||
|
- **THEN** the popover SHALL remain open
|
||||||
|
|
||||||
|
#### Scenario: Popover does not clip at viewport edges
|
||||||
|
- **WHEN** a chip near the right edge of the viewport is hovered
|
||||||
|
- **THEN** the popover SHALL be repositioned horizontally so that it remains within the viewport
|
||||||
|
- **WHEN** a chip is near the bottom of available space
|
||||||
|
- **THEN** the popover SHALL flip above the chip if there is more space above than below
|
||||||
|
|
||||||
|
### Requirement: Horizontal drag-to-reorder on desktop
|
||||||
|
On desktop, the specialization chip strip SHALL support drag-and-drop reordering using a left-to-right horizontal sorting strategy. Dragging a chip and dropping it at a new horizontal position SHALL update the priority ranking accordingly.
|
||||||
|
|
||||||
|
#### Scenario: Drag chip left or right
|
||||||
|
- **WHEN** the user drags a specialization chip to a new horizontal position on desktop
|
||||||
|
- **THEN** the priority ranking SHALL update to reflect the new left-to-right order (leftmost chip is highest priority)
|
||||||
|
|
||||||
|
#### Scenario: Mobile keeps vertical drag
|
||||||
|
- **WHEN** the user drags a specialization on mobile
|
||||||
|
- **THEN** reordering SHALL continue to use vertical drag semantics
|
||||||
|
|
||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Specialization rows include credit progress
|
||||||
|
On mobile, each specialization row in the ranking list SHALL display the credit progress bar and allocated/threshold credits alongside the rank, name, and status badge. The row layout SHALL be: reorder controls, rank number, name, credits (e.g. "7.5 / 9.0"), status badge, with a credit bar below. On desktop the equivalent information SHALL render in the chip + hover-popover combination defined in this capability rather than as full rows.
|
||||||
|
|
||||||
|
#### Scenario: Mobile row displays allocated credits and bar
|
||||||
|
- **WHEN** a specialization has 7.5 allocated credits from pinned courses on mobile
|
||||||
|
- **THEN** the row SHALL show "7.5 / 9.0" and a credit progress bar filled to 7.5/9.0
|
||||||
|
|
||||||
|
#### Scenario: Mobile row displays zero credits
|
||||||
|
- **WHEN** a specialization has no allocated credits on mobile
|
||||||
|
- **THEN** the row SHALL show "0 / 9.0" and an empty credit progress bar with the 9-credit threshold marker visible
|
||||||
|
|
||||||
|
#### Scenario: Desktop chip displays credit progress
|
||||||
|
- **WHEN** a specialization has any allocated credits on desktop
|
||||||
|
- **THEN** the chip SHALL show a micro credit bar reflecting the allocated proportion against the 9-credit threshold, and the popover SHALL display the numeric "X.X / 9.0" value
|
||||||
|
|
||||||
|
### Requirement: Expandable allocation breakdown
|
||||||
|
On mobile, achieved specialization rows SHALL be tappable/clickable to expand and show the allocation breakdown (which courses contribute how many credits). On desktop, the allocation breakdown SHALL render inside the chip's hover popover for achieved specializations rather than via in-place expansion.
|
||||||
|
|
||||||
|
#### Scenario: Mobile tap to expand achieved spec
|
||||||
|
- **WHEN** a user taps an achieved specialization row on mobile
|
||||||
|
- **THEN** the row SHALL expand to show a list of contributing courses and their credit amounts
|
||||||
|
|
||||||
|
#### Scenario: Mobile tap to collapse
|
||||||
|
- **WHEN** a user taps an already-expanded achieved specialization row on mobile
|
||||||
|
- **THEN** the allocation breakdown SHALL collapse
|
||||||
|
|
||||||
|
#### Scenario: Mobile non-achieved specs are not expandable
|
||||||
|
- **WHEN** a user taps a specialization that is not achieved on mobile
|
||||||
|
- **THEN** nothing SHALL happen (no expand/collapse)
|
||||||
|
|
||||||
|
#### Scenario: Desktop allocation breakdown via popover
|
||||||
|
- **WHEN** a user hovers an achieved specialization chip on desktop
|
||||||
|
- **THEN** the chip's popover SHALL show the contributing-courses breakdown (each course name and its credit amount); no in-place expansion of the chip occurs
|
||||||
|
|
||||||
|
### Requirement: Achievement summary
|
||||||
|
The panel SHALL display a summary count showing how many specializations are currently achieved. On mobile, the summary appears above the ranking list. On desktop, the summary appears in the specialization strip's header area (above or alongside the strip).
|
||||||
|
|
||||||
|
#### Scenario: Some achieved
|
||||||
|
- **WHEN** 2 specializations are achieved
|
||||||
|
- **THEN** the panel SHALL display "2 specializations achieved" (mobile: above the list; desktop: in the strip header)
|
||||||
|
|
||||||
|
#### Scenario: None achieved
|
||||||
|
- **WHEN** no specializations are achieved
|
||||||
|
- **THEN** the panel SHALL display "No specializations achieved yet" (mobile: above the list; desktop: in the strip header)
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
## 1. Layout reflow scaffolding
|
||||||
|
|
||||||
|
- [x] 1.1 In `app/src/App.tsx`, replace the desktop branch's 2-column grid (`340px 1fr`) with a vertical flex container holding: header section, mode toggle, mode comparison banner, specialization strip placeholder, progress strip placeholder, and a 1fr 2-column workspace (`1fr 1fr`) for plans/schedule.
|
||||||
|
- [x] 1.2 Ensure each workspace column has `overflowY: auto; min-height: 0` and the parent grid has `flex: 1; min-height: 0` so independent scroll works inside the existing 100vh container.
|
||||||
|
- [x] 1.3 Verify mobile branch JSX is byte-equivalent to today (no regressions to mobile stack, MobileStatusBanner / MobileCourseBanner refs, or scroll-into-view behavior).
|
||||||
|
- [x] 1.4 Move the `CreditLegend` rendering out of the Schedule column. It will be invoked from the spec strip header in step 2.
|
||||||
|
|
||||||
|
## 2. Specialization horizontal strip
|
||||||
|
|
||||||
|
- [x] 2.1 In `app/src/components/SpecializationRanking.tsx`, branch on `useMediaQuery() === 'desktop'` (or the existing `isMobile` derivation if shared) to render a strip variant.
|
||||||
|
- [x] 2.2 Implement the desktop strip container: single-row flex (`display: flex; gap: 6px; overflowX: auto`) so narrow viewports get a horizontal scrollbar on the strip rather than wrap.
|
||||||
|
- [x] 2.3 Implement compact `Chip` element: ~70px wide, vertical stack of {rank number small, abbreviation bold, micro credit bar}. Background color from `STATUS_STYLES[status].bg`. Use the abbreviation field from `SPECIALIZATIONS`.
|
||||||
|
- [x] 2.4 Reuse `CreditBar` rendering at smaller dimensions (height ~4px, no tick marks for the compact variant; only the threshold marker).
|
||||||
|
- [x] 2.5 Replace the `verticalListSortingStrategy` import with `horizontalListSortingStrategy` in the desktop branch (keep vertical for mobile). Reuse the existing `DndContext`, sensors, and `arrayMove` handler.
|
||||||
|
- [x] 2.6 Render the achievement summary ("X specializations achieved" / "No specializations achieved yet") in a strip header row above the chip strip on desktop. Right-align the `[▸ legend]` toggle.
|
||||||
|
- [x] 2.7 Mount the existing `CreditLegend` content as the expandable panel triggered by `[▸ legend]`. The legend toggle and panel can be rendered directly in `App.tsx`'s spec-strip header area, or as a child of `SpecializationRanking`'s strip header — pick one and stay consistent.
|
||||||
|
|
||||||
|
## 3. Specialization chip hover popover
|
||||||
|
|
||||||
|
- [x] 3.1 Build a `SpecChipPopover` component (can live in `SpecializationRanking.tsx`) that takes `specId`, `anchorRect`, `result`, and an `onClose` handler.
|
||||||
|
- [x] 3.2 Compute popover position with the same smart-flip logic as `CourseInfoPopover` (use `spaceBelow`, `spaceAbove`, clamp `left` to viewport).
|
||||||
|
- [x] 3.3 Popover content: full spec name (bold), status word ("Achieved" / "Achievable" / "Missing Required" / "Unreachable") with same color as the badge today, allocated/threshold numeric (e.g., "5.5 / 9.0"), and — only when the spec is achieved — a list of contributing courses with their credit amounts (reuse the existing `AllocationBreakdown` logic).
|
||||||
|
- [x] 3.4 Implement hover-open / hover-close with a 150ms close delay so the cursor can move from chip to popover. Mirror the pattern in `CourseSelection.tsx` (`hoverCloseTimer`, `cancelHoverClose`, `handleHoverOpen`, `handleHoverLeave`).
|
||||||
|
- [x] 3.5 Implement tap-toggle for touch devices: `onClick` toggles open/closed; clicking outside closes (use the same `mousedown` outside-click handler the existing popover uses).
|
||||||
|
- [x] 3.6 Open popover via `onMouseEnter` only when `window.matchMedia('(hover: hover)').matches` (consistent with how the course info popover handles touch).
|
||||||
|
|
||||||
|
## 4. Hoist search progress strip
|
||||||
|
|
||||||
|
- [x] 4.1 In `app/src/components/TopPlans.tsx`, remove the animated progress bar JSX and the percent/iteration sub-line. Keep the static "Search complete · N explored" / "Search incomplete · cap hit at N" status text in the plan header (it's a per-panel summary).
|
||||||
|
- [x] 4.2 Adjust `TopPlansProps` if needed so the parent can still pass `progress` and `loading` (only consumed for the static status text now).
|
||||||
|
- [x] 4.3 Create `app/src/components/SearchProgressStrip.tsx` exporting a component that takes `progress: { iterations; iterationsTotal } | null` and `loading: boolean` and renders the animated bar + iteration count when `loading && progress`. Render nothing otherwise (so it collapses to zero height when idle).
|
||||||
|
- [x] 4.4 Render `SearchProgressStrip` in `App.tsx`'s desktop branch between the spec strip and the workspace columns.
|
||||||
|
- [x] 4.5 On mobile, render the existing inline-progress-bar JSX inside `TopPlans` (gate it on `isMobile`). The desktop strip only renders when `!isMobile`.
|
||||||
|
|
||||||
|
## 5. Schedule horizontal course buttons
|
||||||
|
|
||||||
|
- [x] 5.1 In `app/src/components/CourseSelection.tsx`, change `ElectiveSet`'s unpinned branch container from `display: flex; flexDirection: column; gap: 4px` to `display: flex; gap: 4px` (no flex direction) on desktop. Keep the column layout on mobile via a `useMediaQuery` branch.
|
||||||
|
- [x] 5.2 Each course button gets `flex: '1 1 0'` so buttons stretch to equal width within the row. Set `min-width: 0` on the button to allow text truncation.
|
||||||
|
- [x] 5.3 Restructure the button content into top/middle/bottom rows: top row contains the info icon (left) and the recommended star (right). Middle is the course name with `WebkitLineClamp: 3`. Bottom is the spec ceiling tag row.
|
||||||
|
- [x] 5.4 Add `title={course.name}` so the full name is available on hover for truncated names.
|
||||||
|
- [x] 5.5 Allow the spec-tag row to wrap (`flex-wrap: wrap`); use `flex-grow: 1` on the wrapping container so button heights stretch to match the tallest button in the set.
|
||||||
|
- [x] 5.6 Map cancelled and already-selected states to button styling: gray background, strikethrough name (cancelled only), tiny footer label "(Cancelled)" or "(Already selected)" below the spec-tag row.
|
||||||
|
- [x] 5.7 Map per-course searching state to a pulsing skeleton band where the spec tags would render (reuse the `cell-pulse` keyframe).
|
||||||
|
- [x] 5.8 Map the required-for-spec note to a small amber footer line below the spec tags.
|
||||||
|
- [x] 5.9 Pinned-set branch is unchanged. Term headers (Spring/Summer/Fall) are unchanged.
|
||||||
|
- [x] 5.10 Verify the existing `CourseInfoPopover` invocation (and its hover/click/keyboard handlers) still works inside the new button anatomy — the info icon remains a clickable child element with `stopPropagation`.
|
||||||
|
|
||||||
|
## 6. Wiring and styles
|
||||||
|
|
||||||
|
- [x] 6.1 Confirm `App.tsx` passes `searchProgress` and `treeLoading` down to `SearchProgressStrip` and `TopPlans` correctly with the new responsibilities split.
|
||||||
|
- [x] 6.2 Confirm `useAppState` shape is unchanged (no state-machine edits needed for this refactor).
|
||||||
|
- [x] 6.3 Re-check the `courseSectionRef` and `specSectionRef` IntersectionObserver hooks in `App.tsx`: the refs must still anchor to mobile-only sections (the floating MobileStatusBanner / MobileCourseBanner depend on them).
|
||||||
|
|
||||||
|
## 7. Verification
|
||||||
|
|
||||||
|
- [x] 7.1 Type-check (`npm run build` or `tsc --noEmit`) clean. _Note: vite build passes; `tsc` has pre-existing errors in `feasibility.ts`, `optimizer.ts`, `decisionTree.ts`, `appState.ts`, and an unused `scorer` prop in `CourseSelection.tsx` — all present at v1.3.3 baseline, none introduced by this change._
|
||||||
|
- [x] 7.2 Lint (`npm run lint`) clean. _Note: same pre-existing 11 errors (setState-in-effect in `appState.ts`/`App.tsx`/`SpecializationRanking.tsx` SortableItem, unused vars in solver files, fast-refresh warning on existing `STATUS_STYLES` export). No new errors from this change._
|
||||||
|
- [ ] 7.3 Manual desktop check at 1200px+ viewport: spec strip in single row, chip hover opens popover with correct content, drag-to-reorder works left-right, progress strip animates during search, 2 columns scroll independently, schedule blocks render horizontal button rows, all states render correctly (default / cancelled / already-selected / searching / recommended / required-for). _Deferred — Playwright browser binary not available in this sandbox; user should verify in their dev environment._
|
||||||
|
- [ ] 7.4 Manual desktop check at narrower viewport (~1024px): chip strip gets internal horizontal scrollbar without wrapping; column split still readable; schedule buttons still legible at minimum width. _Deferred — same as 7.3._
|
||||||
|
- [ ] 7.5 Manual mobile check (≤768px): vertical specialization list with arrows + drag, vertical-stacked schedule rows, progress bar inside Top Plans (not hoisted), MobileStatusBanner and MobileCourseBanner still appear when their respective sections scroll out of view. _Deferred — same as 7.3._
|
||||||
|
- [x] 7.6 Existing unit tests pass (`npm test`). _Verified: 84 tests pass across 6 test files._
|
||||||
|
|
||||||
|
## 8. Release
|
||||||
|
|
||||||
|
- [x] 8.1 Bump version to `1.4.0` in `app/vite.config.ts`.
|
||||||
|
- [x] 8.2 Add a `1.4.0` entry to `CHANGELOG.md` summarizing the desktop redesign (horizontal spec strip, hoisted progress, 2-col workspace, horizontal schedule buttons, mobile unchanged).
|
||||||
|
- [x] 8.3 Build (`npm run build`) and verify the production bundle. _Verified via `npx vite build`: 403 KB JS bundle, 0.5 KB CSS, built in ~1.3s._
|
||||||
Reference in New Issue
Block a user