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:
+124
-66
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useRef, useState, useEffect, useCallback } from 'react';
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
import { useAppState } from './state/appState';
|
||||
import { useMediaQuery } from './hooks/useMediaQuery';
|
||||
import { SpecializationRanking } from './components/SpecializationRanking';
|
||||
@@ -6,11 +6,12 @@ import { ModeToggle } from './components/ModeToggle';
|
||||
import { CourseSelection } from './components/CourseSelection';
|
||||
import { CreditLegend } from './components/CreditLegend';
|
||||
import { TopPlans } from './components/TopPlans';
|
||||
import { SearchProgressStrip } from './components/SearchProgressStrip';
|
||||
import { ModeComparison } from './components/Notifications';
|
||||
import { MobileStatusBanner } from './components/MobileStatusBanner';
|
||||
import { MobileCourseBanner } from './components/MobileCourseBanner';
|
||||
import { optimize } from './solver/optimizer';
|
||||
|
||||
type MobileTab = 'specs' | 'plans' | 'courses';
|
||||
|
||||
function App() {
|
||||
const {
|
||||
state,
|
||||
@@ -43,43 +44,9 @@ function App() {
|
||||
|
||||
const isMobile = breakpoint === 'mobile';
|
||||
|
||||
const [mobileTab, setMobileTab] = useState<MobileTab>('specs');
|
||||
const specSectionRef = useRef<HTMLDivElement>(null);
|
||||
const [bannerVisible, setBannerVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMobile || !specSectionRef.current) {
|
||||
setBannerVisible(false);
|
||||
return;
|
||||
}
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => setBannerVisible(!entry.isIntersecting),
|
||||
);
|
||||
observer.observe(specSectionRef.current);
|
||||
return () => observer.disconnect();
|
||||
}, [isMobile]);
|
||||
|
||||
const handleBannerTap = useCallback(() => {
|
||||
specSectionRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, []);
|
||||
|
||||
const courseSectionRef = useRef<HTMLDivElement>(null);
|
||||
const [courseBannerVisible, setCourseBannerVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMobile || !courseSectionRef.current) {
|
||||
setCourseBannerVisible(false);
|
||||
return;
|
||||
}
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => setCourseBannerVisible(!entry.isIntersecting),
|
||||
);
|
||||
observer.observe(courseSectionRef.current);
|
||||
return () => observer.disconnect();
|
||||
}, [isMobile]);
|
||||
|
||||
const handleCourseBannerTap = useCallback(() => {
|
||||
courseSectionRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, []);
|
||||
|
||||
const containerStyle: React.CSSProperties = {
|
||||
maxWidth: '1200px',
|
||||
@@ -89,27 +56,114 @@ function App() {
|
||||
...(isMobile ? {} : { height: '100vh', display: 'flex', flexDirection: 'column', overflow: 'hidden' }),
|
||||
};
|
||||
|
||||
const panelStyle: React.CSSProperties = isMobile
|
||||
? { display: 'flex', flexDirection: 'column', gap: '20px' }
|
||||
: { display: 'grid', gridTemplateColumns: '340px 1fr', gap: '24px', alignItems: 'stretch', flex: 1, minHeight: 0 };
|
||||
if (isMobile) {
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
<h1 style={{ fontSize: '20px', marginBottom: '2px', color: '#111' }}>
|
||||
EMBA Specialization Solver
|
||||
</h1>
|
||||
<div style={{ fontSize: '11px', color: '#999', marginBottom: '12px' }}>v{__APP_VERSION__} ({__APP_VERSION_DATE__})</div>
|
||||
|
||||
<ModeToggle mode={state.mode} onSetMode={setMode} />
|
||||
|
||||
<ModeComparison
|
||||
result={optimizationResult}
|
||||
altResult={altResult}
|
||||
altModeName={altMode === 'maximize-count' ? 'Maximize Count' : 'Priority Order'}
|
||||
/>
|
||||
|
||||
<SearchProgressStrip loading={treeLoading} progress={searchProgress} />
|
||||
|
||||
<div role="tablist" aria-label="Sections" style={{
|
||||
display: 'grid', gridTemplateColumns: '1fr 1fr 1fr',
|
||||
gap: '4px', marginBottom: '12px',
|
||||
background: '#f1f5f9', borderRadius: '8px', padding: '3px',
|
||||
}}>
|
||||
{([
|
||||
{ id: 'specs', label: 'Specializations' },
|
||||
{ id: 'plans', label: 'Plans' },
|
||||
{ id: 'courses', label: 'Courses' },
|
||||
] as const).map((t) => {
|
||||
const active = mobileTab === t.id;
|
||||
return (
|
||||
<button
|
||||
key={t.id}
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
onClick={() => setMobileTab(t.id)}
|
||||
style={{
|
||||
fontSize: '12px', fontWeight: 600,
|
||||
padding: '8px 4px',
|
||||
border: 'none', borderRadius: '6px',
|
||||
background: active ? '#fff' : 'transparent',
|
||||
color: active ? '#1e293b' : '#64748b',
|
||||
boxShadow: active ? '0 1px 2px rgba(0,0,0,0.08)' : 'none',
|
||||
cursor: 'pointer',
|
||||
transition: 'background 150ms, color 150ms',
|
||||
}}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{mobileTab === 'specs' && (
|
||||
<div role="tabpanel" ref={specSectionRef}>
|
||||
<CreditLegend />
|
||||
<SpecializationRanking
|
||||
ranking={state.ranking}
|
||||
result={optimizationResult}
|
||||
onReorder={reorder}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{mobileTab === 'plans' && (
|
||||
<div role="tabpanel">
|
||||
<TopPlans
|
||||
plans={topPlans}
|
||||
partial={topPlansPartial}
|
||||
loading={treeLoading}
|
||||
progress={searchProgress}
|
||||
pinnedCourses={state.pinnedCourses}
|
||||
ranking={state.ranking}
|
||||
showAnimatedBar={false}
|
||||
onAdopt={adoptPlan}
|
||||
onPin={pinCourse}
|
||||
onUnpin={unpinCourse}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{mobileTab === 'courses' && (
|
||||
<div role="tabpanel" ref={courseSectionRef}>
|
||||
<CourseSelection
|
||||
pinnedCourses={state.pinnedCourses}
|
||||
treeResults={treeResults}
|
||||
treeLoading={treeLoading}
|
||||
disabledCourseIds={disabledCourseIds}
|
||||
ranking={state.ranking}
|
||||
mode={state.mode}
|
||||
onPin={pinCourse}
|
||||
onUnpin={unpinCourse}
|
||||
onClearAll={clearAll}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Desktop layout: top spec strip + global progress strip + 2-col workspace
|
||||
const workspaceStyle: React.CSSProperties = {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr',
|
||||
gap: '24px',
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
{isMobile && (
|
||||
<>
|
||||
<MobileStatusBanner
|
||||
statuses={optimizationResult.statuses}
|
||||
visible={bannerVisible}
|
||||
onTap={handleBannerTap}
|
||||
/>
|
||||
<MobileCourseBanner
|
||||
selectedCount={Object.keys(state.pinnedCourses).length}
|
||||
totalSets={12}
|
||||
visible={courseBannerVisible}
|
||||
onTap={handleCourseBannerTap}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<h1 style={{ fontSize: '20px', marginBottom: '2px', color: '#111' }}>
|
||||
EMBA Specialization Solver
|
||||
</h1>
|
||||
@@ -123,16 +177,17 @@ function App() {
|
||||
altModeName={altMode === 'maximize-count' ? 'Maximize Count' : 'Priority Order'}
|
||||
/>
|
||||
|
||||
<div style={panelStyle}>
|
||||
<div ref={specSectionRef} style={isMobile ? {} : { overflowY: 'auto', minHeight: 0 }}>
|
||||
<CreditLegend />
|
||||
<SpecializationRanking
|
||||
ranking={state.ranking}
|
||||
result={optimizationResult}
|
||||
onReorder={reorder}
|
||||
/>
|
||||
</div>
|
||||
<div ref={courseSectionRef} style={isMobile ? {} : { overflowY: 'auto', minHeight: 0 }}>
|
||||
<SpecializationRanking
|
||||
ranking={state.ranking}
|
||||
result={optimizationResult}
|
||||
onReorder={reorder}
|
||||
headerSlot={<CreditLegend />}
|
||||
/>
|
||||
|
||||
<SearchProgressStrip loading={treeLoading} progress={searchProgress} />
|
||||
|
||||
<div style={workspaceStyle}>
|
||||
<div style={{ overflowY: 'auto', minHeight: 0, paddingRight: '4px' }}>
|
||||
<TopPlans
|
||||
plans={topPlans}
|
||||
partial={topPlansPartial}
|
||||
@@ -140,10 +195,13 @@ function App() {
|
||||
progress={searchProgress}
|
||||
pinnedCourses={state.pinnedCourses}
|
||||
ranking={state.ranking}
|
||||
showAnimatedBar={false}
|
||||
onAdopt={adoptPlan}
|
||||
onPin={pinCourse}
|
||||
onUnpin={unpinCourse}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ overflowY: 'auto', minHeight: 0, paddingRight: '4px' }}>
|
||||
<CourseSelection
|
||||
pinnedCourses={state.pinnedCourses}
|
||||
treeResults={treeResults}
|
||||
|
||||
@@ -216,6 +216,7 @@ function ElectiveSet({
|
||||
scorer,
|
||||
rankWeight,
|
||||
mode,
|
||||
isMobile,
|
||||
onPin,
|
||||
onUnpin,
|
||||
openPopoverId,
|
||||
@@ -233,6 +234,7 @@ function ElectiveSet({
|
||||
scorer: (specs: string[]) => number;
|
||||
rankWeight: (specs: string[]) => number;
|
||||
mode: OptimizationMode;
|
||||
isMobile: boolean;
|
||||
onPin: (courseId: string) => void;
|
||||
onUnpin: () => void;
|
||||
openPopoverId: string | null;
|
||||
@@ -282,15 +284,32 @@ function ElectiveSet({
|
||||
border: isPinned ? '1px solid #3b82f6' : '1px solid #ccc',
|
||||
borderStyle: isPinned ? 'solid' : 'dashed',
|
||||
borderRadius: '8px',
|
||||
padding: '12px',
|
||||
padding: isPinned ? '8px 12px' : '12px',
|
||||
marginBottom: '8px',
|
||||
background: isPinned ? '#eff6ff' : '#fafafa',
|
||||
transition: 'border-color 200ms, background-color 200ms',
|
||||
transition: 'border-color 200ms, background-color 200ms, padding 200ms',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' }}>
|
||||
<h4 style={{ fontSize: '13px', margin: 0, color: '#444', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span>{setName}</span>
|
||||
<div style={{
|
||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||
gap: '8px',
|
||||
marginBottom: isPinned ? 0 : '8px',
|
||||
}}>
|
||||
<h4 style={{
|
||||
fontSize: '13px', margin: 0, color: '#444',
|
||||
display: 'flex', alignItems: 'baseline', gap: '8px', flexWrap: 'wrap',
|
||||
minWidth: 0, flex: 1,
|
||||
}}>
|
||||
<span style={{ flexShrink: 0 }}>{setName}{isPinned ? ':' : ''}</span>
|
||||
{isPinned && pinnedCourse && (
|
||||
<span style={{
|
||||
fontSize: '13px', fontWeight: 600, color: '#1e40af',
|
||||
minWidth: 0,
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{pinnedCourse.name}
|
||||
</span>
|
||||
)}
|
||||
{!isPinned && setSearching && (
|
||||
<span style={{
|
||||
display: 'inline-block', width: '10px', height: '10px', borderRadius: '50%',
|
||||
@@ -302,10 +321,11 @@ function ElectiveSet({
|
||||
<span style={{ fontSize: '11px', color: '#d97706', fontWeight: 400 }}>high impact</span>
|
||||
)}
|
||||
</h4>
|
||||
{!isPinned && (
|
||||
{!isPinned && isMobile && (
|
||||
<span style={{
|
||||
fontSize: '10px', color: '#94a3b8', fontWeight: 500, letterSpacing: '0.3px',
|
||||
textTransform: 'uppercase',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
top outcome if picked ↓
|
||||
</span>
|
||||
@@ -317,23 +337,13 @@ function ElectiveSet({
|
||||
fontSize: '11px', border: '1px solid #bfdbfe', background: '#eff6ff',
|
||||
color: '#2563eb', cursor: 'pointer', padding: '3px 10px',
|
||||
borderRadius: '4px', fontWeight: 500,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* Pinned view */}
|
||||
<div style={{
|
||||
maxHeight: isPinned ? '40px' : '0',
|
||||
opacity: isPinned ? 1 : 0,
|
||||
overflow: 'hidden',
|
||||
transition: 'max-height 250ms ease-out, opacity 200ms',
|
||||
}}>
|
||||
<div style={{ fontSize: '14px', fontWeight: 600, color: '#1e40af' }}>
|
||||
{pinnedCourse?.name}
|
||||
</div>
|
||||
</div>
|
||||
{/* Course list view */}
|
||||
<div style={{
|
||||
maxHeight: isPinned ? '0' : '500px',
|
||||
@@ -342,7 +352,12 @@ function ElectiveSet({
|
||||
pointerEvents: isPinned ? 'none' : 'auto',
|
||||
transition: 'max-height 250ms ease-out, opacity 200ms',
|
||||
}}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: isMobile ? 'column' : 'row',
|
||||
alignItems: isMobile ? 'stretch' : 'stretch',
|
||||
gap: '4px',
|
||||
}}>
|
||||
{courses.map((course) => {
|
||||
const isCancelled = !!course.cancelled;
|
||||
const isDisabled = disabledCourseIds.has(course.id);
|
||||
@@ -353,137 +368,224 @@ function ElectiveSet({
|
||||
const cellSearching = !!ceiling && !ceiling.evaluated;
|
||||
const isRecommended = recommendedCourseId === course.id;
|
||||
const hasInfo = !!COURSE_DESCRIPTIONS[course.id];
|
||||
|
||||
const infoIcon = !isUnavailable && hasInfo ? (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`Info about ${course.name}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
if (openPopoverId === course.id) {
|
||||
onClosePopover();
|
||||
} else {
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
onOpenPopover(course.id, rect);
|
||||
}
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (window.matchMedia('(hover: hover)').matches) {
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
onHoverOpen(course.id, rect);
|
||||
}
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
if (window.matchMedia('(hover: hover)').matches) {
|
||||
onHoverLeave();
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
if (openPopoverId === course.id) {
|
||||
onClosePopover();
|
||||
} else {
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
onOpenPopover(course.id, rect);
|
||||
}
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: '16px', height: '16px', borderRadius: '50%',
|
||||
border: '1px solid #cbd5e1', background: openPopoverId === course.id ? '#e0e7ff' : '#f1f5f9',
|
||||
color: '#6366f1', fontSize: '10px', fontWeight: 700,
|
||||
cursor: 'pointer', flexShrink: 0,
|
||||
fontStyle: 'normal', textDecoration: 'none',
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
i
|
||||
</span>
|
||||
) : null;
|
||||
|
||||
const ceilingTags = !isUnavailable && (showSkeleton || cellSearching) ? (
|
||||
<span style={{
|
||||
fontSize: '11px', color: '#94a3b8', fontStyle: 'italic',
|
||||
display: 'inline-flex', alignItems: 'center', gap: '4px',
|
||||
}}>
|
||||
<span style={{
|
||||
display: 'inline-block', width: '8px', height: '8px', borderRadius: '50%',
|
||||
background: '#cbd5e1', animation: 'cell-pulse 1.2s ease-in-out infinite',
|
||||
}} />
|
||||
searching
|
||||
</span>
|
||||
) : !isUnavailable && ceiling && ceiling.evaluated ? (
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: '4px',
|
||||
flexWrap: 'wrap', justifyContent: isMobile ? 'flex-end' : 'flex-start',
|
||||
}}>
|
||||
{ceiling.ceilingSpecs.length === 0 ? (
|
||||
<span style={{ fontSize: '11px', color: '#9ca3af', fontStyle: 'italic' }}>
|
||||
no specs
|
||||
</span>
|
||||
) : (
|
||||
ceiling.ceilingSpecs.map((s) => <SpecTag key={s} specId={s} />)
|
||||
)}
|
||||
</span>
|
||||
) : null;
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<button
|
||||
key={course.id}
|
||||
onClick={isUnavailable ? undefined : () => onPin(course.id)}
|
||||
disabled={isUnavailable}
|
||||
style={{
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'stretch',
|
||||
textAlign: 'left', padding: '6px 10px',
|
||||
border: '1px solid #e5e7eb', borderRadius: '4px',
|
||||
background: isUnavailable ? '#f5f5f5' : '#fff',
|
||||
cursor: isUnavailable ? 'default' : 'pointer',
|
||||
fontSize: '13px',
|
||||
color: isUnavailable ? '#bbb' : '#333',
|
||||
pointerEvents: isUnavailable ? 'none' : 'auto',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{
|
||||
flex: 1,
|
||||
textDecoration: isCancelled ? 'line-through' : 'none',
|
||||
fontStyle: isCancelled ? 'italic' : 'normal',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
}}>
|
||||
{course.name}
|
||||
{isCancelled && (
|
||||
<span style={{ fontSize: '11px', color: '#999', marginLeft: '6px', fontStyle: 'normal', textDecoration: 'none' }}>
|
||||
(Cancelled)
|
||||
</span>
|
||||
)}
|
||||
{!isCancelled && isDisabled && (
|
||||
<span style={{ fontSize: '11px', color: '#999', marginLeft: '6px' }}>
|
||||
(Already selected)
|
||||
</span>
|
||||
)}
|
||||
{isRecommended && !isUnavailable && (
|
||||
<span
|
||||
title="Best outcome among the choices in this set"
|
||||
style={{
|
||||
fontSize: '10px', color: '#15803d', fontWeight: 600,
|
||||
display: 'inline-flex', alignItems: 'center', gap: '2px',
|
||||
padding: '0 4px', borderRadius: '3px', background: '#dcfce7',
|
||||
border: '1px solid #bbf7d0', lineHeight: 1.2,
|
||||
}}
|
||||
>
|
||||
<span aria-hidden="true">★</span>
|
||||
<span>Recommended</span>
|
||||
</span>
|
||||
)}
|
||||
{infoIcon}
|
||||
</span>
|
||||
{ceilingTags}
|
||||
</div>
|
||||
{reqFor && !isUnavailable && (
|
||||
<span style={{ fontSize: '11px', color: '#92400e', marginTop: '2px' }}>
|
||||
Required for {reqFor.join(', ')}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// Desktop horizontal button
|
||||
return (
|
||||
<button
|
||||
key={course.id}
|
||||
onClick={isUnavailable ? undefined : () => onPin(course.id)}
|
||||
disabled={isUnavailable}
|
||||
title={course.name}
|
||||
style={{
|
||||
flex: '1 1 0',
|
||||
minWidth: 0,
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'stretch',
|
||||
textAlign: 'left', padding: '6px 10px',
|
||||
border: '1px solid #e5e7eb', borderRadius: '4px',
|
||||
background: isUnavailable ? '#f5f5f5' : '#fff',
|
||||
textAlign: 'left', padding: '6px 8px', gap: '4px',
|
||||
border: isRecommended && !isUnavailable ? '1px solid #bbf7d0' : '1px solid #e5e7eb',
|
||||
borderRadius: '4px',
|
||||
background: isUnavailable ? '#f5f5f5' : isRecommended ? '#f0fdf4' : '#fff',
|
||||
cursor: isUnavailable ? 'default' : 'pointer',
|
||||
fontSize: '13px',
|
||||
fontSize: '12px',
|
||||
color: isUnavailable ? '#bbb' : '#333',
|
||||
pointerEvents: isUnavailable ? 'none' : 'auto',
|
||||
font: 'inherit',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{
|
||||
flex: 1,
|
||||
textDecoration: isCancelled ? 'line-through' : 'none',
|
||||
fontStyle: isCancelled ? 'italic' : 'normal',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
}}>
|
||||
{course.name}
|
||||
{isCancelled && (
|
||||
<span style={{ fontSize: '11px', color: '#999', marginLeft: '6px', fontStyle: 'normal', textDecoration: 'none' }}>
|
||||
(Cancelled)
|
||||
</span>
|
||||
)}
|
||||
{!isCancelled && isDisabled && (
|
||||
<span style={{ fontSize: '11px', color: '#999', marginLeft: '6px' }}>
|
||||
(Already selected)
|
||||
</span>
|
||||
)}
|
||||
{isRecommended && !isUnavailable && (
|
||||
<span
|
||||
title="Best outcome among the choices in this set"
|
||||
style={{
|
||||
fontSize: '10px', color: '#15803d', fontWeight: 600,
|
||||
display: 'inline-flex', alignItems: 'center', gap: '2px',
|
||||
padding: '0 4px', borderRadius: '3px', background: '#dcfce7',
|
||||
border: '1px solid #bbf7d0', lineHeight: 1.2,
|
||||
}}
|
||||
>
|
||||
<span aria-hidden="true">★</span>
|
||||
<span>Recommended</span>
|
||||
</span>
|
||||
)}
|
||||
{!isUnavailable && hasInfo && (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`Info about ${course.name}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
if (openPopoverId === course.id) {
|
||||
onClosePopover();
|
||||
} else {
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
onOpenPopover(course.id, rect);
|
||||
}
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (window.matchMedia('(hover: hover)').matches) {
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
onHoverOpen(course.id, rect);
|
||||
}
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
if (window.matchMedia('(hover: hover)').matches) {
|
||||
onHoverLeave();
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
if (openPopoverId === course.id) {
|
||||
onClosePopover();
|
||||
} else {
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
onOpenPopover(course.id, rect);
|
||||
}
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: '16px', height: '16px', borderRadius: '50%',
|
||||
border: '1px solid #cbd5e1', background: openPopoverId === course.id ? '#e0e7ff' : '#f1f5f9',
|
||||
color: '#6366f1', fontSize: '10px', fontWeight: 700,
|
||||
cursor: 'pointer', flexShrink: 0,
|
||||
fontStyle: 'normal', textDecoration: 'none',
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
i
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{!isUnavailable && (showSkeleton || cellSearching) ? (
|
||||
<span style={{
|
||||
fontSize: '11px', color: '#94a3b8', fontStyle: 'italic',
|
||||
display: 'inline-flex', alignItems: 'center', gap: '4px',
|
||||
}}>
|
||||
<span style={{
|
||||
display: 'inline-block', width: '8px', height: '8px', borderRadius: '50%',
|
||||
background: '#cbd5e1', animation: 'cell-pulse 1.2s ease-in-out infinite',
|
||||
}} />
|
||||
searching
|
||||
<div style={{
|
||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||
minHeight: '16px',
|
||||
}}>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center' }}>{infoIcon}</span>
|
||||
{isRecommended && !isUnavailable && (
|
||||
<span
|
||||
title="Best outcome among the choices in this set"
|
||||
aria-label="Recommended"
|
||||
style={{
|
||||
fontSize: '12px', color: '#15803d', lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
★
|
||||
</span>
|
||||
) : !isUnavailable && ceiling && ceiling.evaluated ? (
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: '4px',
|
||||
whiteSpace: 'nowrap', flexWrap: 'wrap', justifyContent: 'flex-end',
|
||||
}}>
|
||||
{ceiling.ceilingSpecs.length === 0 ? (
|
||||
<span style={{ fontSize: '11px', color: '#9ca3af', fontStyle: 'italic' }}>
|
||||
no specs
|
||||
</span>
|
||||
) : (
|
||||
ceiling.ceilingSpecs.map((s) => <SpecTag key={s} specId={s} />)
|
||||
)}
|
||||
</span>
|
||||
) : null}
|
||||
)}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
color: isUnavailable ? '#bbb' : '#333',
|
||||
fontWeight: isRecommended && !isUnavailable ? 600 : 500,
|
||||
lineHeight: 1.3,
|
||||
textDecoration: isCancelled ? 'line-through' : 'none',
|
||||
fontStyle: isCancelled ? 'italic' : 'normal',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 3,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
flex: 1,
|
||||
}}>
|
||||
{course.name}
|
||||
</div>
|
||||
{ceilingTags && (
|
||||
<div style={{
|
||||
display: 'flex', flexWrap: 'wrap', gap: '3px',
|
||||
marginTop: 'auto', minHeight: '18px',
|
||||
}}>
|
||||
{ceilingTags}
|
||||
</div>
|
||||
)}
|
||||
{isCancelled && (
|
||||
<span style={{ fontSize: '10px', color: '#999', fontStyle: 'normal', textDecoration: 'none' }}>
|
||||
Cancelled
|
||||
</span>
|
||||
)}
|
||||
{!isCancelled && isDisabled && (
|
||||
<span style={{ fontSize: '10px', color: '#999' }}>
|
||||
Already selected
|
||||
</span>
|
||||
)}
|
||||
{reqFor && !isUnavailable && (
|
||||
<span style={{ fontSize: '11px', color: '#92400e', marginTop: '2px' }}>
|
||||
Required for {reqFor.join(', ')}
|
||||
<span style={{ fontSize: '10px', color: '#92400e', lineHeight: 1.3 }}>
|
||||
Req. for {reqFor.join(', ')}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
@@ -506,6 +608,8 @@ export function CourseSelection({ pinnedCourses, treeResults, treeLoading, disab
|
||||
const rankWeight = useMemo(() => makePriorityRankWeight(ranking), [ranking]);
|
||||
const terms: Term[] = ['Spring', 'Summer', 'Fall'];
|
||||
const hasPinned = Object.keys(pinnedCourses).length > 0;
|
||||
const breakpoint = useMediaQuery();
|
||||
const isMobile = breakpoint === 'mobile';
|
||||
|
||||
// Index tree results by setId for O(1) lookup
|
||||
const treeBySet = new Map(treeResults.map((a) => [a.setId, a]));
|
||||
@@ -586,6 +690,7 @@ export function CourseSelection({ pinnedCourses, treeResults, treeLoading, disab
|
||||
scorer={scorer}
|
||||
rankWeight={rankWeight}
|
||||
mode={mode}
|
||||
isMobile={isMobile}
|
||||
onPin={(courseId) => onPin(set.id, courseId)}
|
||||
onUnpin={() => onUnpin(set.id)}
|
||||
openPopoverId={popover?.courseId ?? null}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
interface SearchProgressStripProps {
|
||||
loading: boolean;
|
||||
progress: { iterations: number; iterationsTotal: number } | null;
|
||||
}
|
||||
|
||||
function formatNum(n: number): string {
|
||||
return n.toLocaleString();
|
||||
}
|
||||
|
||||
export function SearchProgressStrip({ loading, progress }: SearchProgressStripProps) {
|
||||
if (!loading || !progress) return null;
|
||||
const pct = progress.iterationsTotal > 0
|
||||
? Math.min(100, (progress.iterations / progress.iterationsTotal) * 100)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div style={{ margin: '4px 0 12px' }}>
|
||||
<div style={{
|
||||
position: 'relative', height: '6px', background: '#e5e7eb',
|
||||
borderRadius: '3px', overflow: 'hidden',
|
||||
}}>
|
||||
<div style={{
|
||||
position: 'absolute', top: 0, left: 0, height: '100%',
|
||||
width: `${pct}%`, background: '#3b82f6',
|
||||
transition: 'width 150ms ease-out',
|
||||
}} />
|
||||
</div>
|
||||
<div style={{
|
||||
display: 'flex', justifyContent: 'space-between',
|
||||
fontSize: '10px', color: '#888', marginTop: '2px',
|
||||
}}>
|
||||
<span>Searching…</span>
|
||||
<span>
|
||||
{formatNum(progress.iterations)} / {formatNum(progress.iterationsTotal)}
|
||||
{' · '}{Math.round(pct)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
@@ -14,11 +14,14 @@ import {
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
rectSortingStrategy,
|
||||
arrayMove,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { SPECIALIZATIONS } from '../data/specializations';
|
||||
import { courseById } from '../data/lookups';
|
||||
import { specColor } from '../data/specColors';
|
||||
import { useMediaQuery } from '../hooks/useMediaQuery';
|
||||
import type { SpecStatus, AllocationResult } from '../data/types';
|
||||
|
||||
export const STATUS_STYLES: Record<SpecStatus, { bg: string; color: string; label: string }> = {
|
||||
@@ -217,10 +220,14 @@ function SortableItem({ id, rank, total, name, status, allocated, potential, isE
|
||||
interface SpecializationRankingProps {
|
||||
ranking: string[];
|
||||
result: AllocationResult;
|
||||
headerSlot?: React.ReactNode;
|
||||
onReorder: (ranking: string[]) => void;
|
||||
}
|
||||
|
||||
export function SpecializationRanking({ ranking, result, onReorder }: SpecializationRankingProps) {
|
||||
export function SpecializationRanking({ ranking, result, headerSlot, onReorder }: SpecializationRankingProps) {
|
||||
const breakpoint = useMediaQuery();
|
||||
const isMobile = breakpoint === 'mobile';
|
||||
|
||||
const [expanded, setExpanded] = useState<Set<string>>(() => new Set(result.achieved));
|
||||
const prevAchievedRef = useRef(result.achieved);
|
||||
|
||||
@@ -271,6 +278,24 @@ export function SpecializationRanking({ ranking, result, onReorder }: Specializa
|
||||
}
|
||||
|
||||
const specMap = new Map(SPECIALIZATIONS.map((s) => [s.id, s]));
|
||||
const achievedSummary = result.achieved.length > 0
|
||||
? `${result.achieved.length} of ${ranking.length} achieved`
|
||||
: 'No specializations achieved yet';
|
||||
|
||||
if (!isMobile) {
|
||||
return (
|
||||
<DesktopSpecStrip
|
||||
ranking={ranking}
|
||||
result={result}
|
||||
sensors={sensors}
|
||||
onDragEnd={handleDragEnd}
|
||||
getAllocatedCredits={getAllocatedCredits}
|
||||
specMap={specMap}
|
||||
achievedSummary={achievedSummary}
|
||||
headerSlot={headerSlot}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -305,3 +330,357 @@ export function SpecializationRanking({ ranking, result, onReorder }: Specializa
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== Desktop strip variant =====
|
||||
|
||||
interface DesktopSpecStripProps {
|
||||
ranking: string[];
|
||||
result: AllocationResult;
|
||||
sensors: ReturnType<typeof useSensors>;
|
||||
onDragEnd: (event: DragEndEvent) => void;
|
||||
getAllocatedCredits: (specId: string) => number;
|
||||
specMap: Map<string, { id: string; name: string; abbreviation: string }>;
|
||||
achievedSummary: string;
|
||||
headerSlot?: React.ReactNode;
|
||||
}
|
||||
|
||||
function DesktopSpecStrip({ ranking, result, sensors, onDragEnd, getAllocatedCredits, specMap, achievedSummary, headerSlot }: DesktopSpecStripProps) {
|
||||
const [popover, setPopover] = useState<{ specId: string; anchorRect: DOMRect } | null>(null);
|
||||
const hoverCloseTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const cancelHoverClose = useCallback(() => {
|
||||
if (hoverCloseTimer.current) {
|
||||
clearTimeout(hoverCloseTimer.current);
|
||||
hoverCloseTimer.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const openPopover = useCallback((specId: string, rect: DOMRect) => {
|
||||
cancelHoverClose();
|
||||
setPopover({ specId, anchorRect: rect });
|
||||
}, [cancelHoverClose]);
|
||||
|
||||
const closePopover = useCallback(() => {
|
||||
cancelHoverClose();
|
||||
setPopover(null);
|
||||
}, [cancelHoverClose]);
|
||||
|
||||
const handleHoverLeave = useCallback(() => {
|
||||
hoverCloseTimer.current = setTimeout(() => {
|
||||
setPopover(null);
|
||||
}, 150);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'baseline', justifyContent: 'space-between',
|
||||
gap: '12px', marginBottom: '6px', flexWrap: 'wrap',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: '12px', flexWrap: 'wrap' }}>
|
||||
<h2 style={{ fontSize: '16px', margin: 0 }}>Specializations</h2>
|
||||
<span style={{ fontSize: '11px', color: '#888' }}>
|
||||
drag left → right to rank · {achievedSummary}
|
||||
</span>
|
||||
</div>
|
||||
{headerSlot && <div style={{ display: 'flex', alignItems: 'baseline' }}>{headerSlot}</div>}
|
||||
</div>
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
|
||||
<SortableContext items={ranking} strategy={rectSortingStrategy}>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: `repeat(${Math.ceil(ranking.length / 2)}, minmax(0, 1fr))`,
|
||||
gap: '6px',
|
||||
paddingBottom: '4px',
|
||||
}}>
|
||||
{ranking.map((id, i) => (
|
||||
<SpecChip
|
||||
key={id}
|
||||
id={id}
|
||||
rank={i + 1}
|
||||
name={specMap.get(id)?.name ?? id}
|
||||
abbreviation={specMap.get(id)?.abbreviation ?? id}
|
||||
status={result.statuses[id]}
|
||||
allocated={getAllocatedCredits(id)}
|
||||
potential={result.upperBounds[id] || 0}
|
||||
isOpen={popover?.specId === id}
|
||||
onHoverOpen={openPopover}
|
||||
onHoverLeave={handleHoverLeave}
|
||||
onTapToggle={(specId, rect) => {
|
||||
if (popover?.specId === specId) closePopover();
|
||||
else openPopover(specId, rect);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
{popover && (
|
||||
<SpecChipPopover
|
||||
specId={popover.specId}
|
||||
name={specMap.get(popover.specId)?.name ?? popover.specId}
|
||||
status={result.statuses[popover.specId]}
|
||||
allocated={getAllocatedCredits(popover.specId)}
|
||||
allocations={result.allocations}
|
||||
anchorRect={popover.anchorRect}
|
||||
onClose={closePopover}
|
||||
onHoverEnter={cancelHoverClose}
|
||||
onHoverLeave={handleHoverLeave}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SpecChipProps {
|
||||
id: string;
|
||||
rank: number;
|
||||
name: string;
|
||||
abbreviation: string;
|
||||
status: SpecStatus;
|
||||
allocated: number;
|
||||
potential: number;
|
||||
isOpen: boolean;
|
||||
onHoverOpen: (specId: string, rect: DOMRect) => void;
|
||||
onHoverLeave: () => void;
|
||||
onTapToggle: (specId: string, rect: DOMRect) => void;
|
||||
}
|
||||
|
||||
function SpecChip({ id, rank, name, abbreviation, status, allocated, potential, isOpen, onHoverOpen, onHoverLeave, onTapToggle }: SpecChipProps) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition: dndTransition,
|
||||
isDragging,
|
||||
} = useSortable({ id });
|
||||
|
||||
const style = STATUS_STYLES[status] || STATUS_STYLES.unreachable;
|
||||
const tagColor = specColor(id);
|
||||
const threshold = 9;
|
||||
const denom = Math.max(potential, threshold);
|
||||
const allocPct = Math.min((allocated / denom) * 100, 100);
|
||||
const potentialPct = Math.min((potential / denom) * 100, 100);
|
||||
const thresholdPct = Math.min((threshold / denom) * 100, 100);
|
||||
|
||||
const handleMouseEnter = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (window.matchMedia('(hover: hover)').matches) {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
onHoverOpen(id, rect);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
if (window.matchMedia('(hover: hover)').matches) {
|
||||
onHoverLeave();
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
onTapToggle(id, rect);
|
||||
};
|
||||
|
||||
const statusGlyph =
|
||||
status === 'achieved' ? '✓' :
|
||||
status === 'achievable' ? '·' :
|
||||
status === 'missing_required' ? '!' : '—';
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onClick={handleClick}
|
||||
title={name}
|
||||
style={{
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition: dndTransition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
minWidth: 0,
|
||||
height: '70px',
|
||||
borderRadius: '6px',
|
||||
background: isDragging ? '#e8e8e8' : style.bg,
|
||||
border: isOpen ? `2px solid ${style.color}` : '1px solid #ddd',
|
||||
padding: isOpen ? '5px 7px' : '6px 8px',
|
||||
cursor: 'grab',
|
||||
touchAction: 'none',
|
||||
userSelect: 'none',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'stretch',
|
||||
gap: '3px',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: '4px',
|
||||
fontSize: '11px', lineHeight: 1.2,
|
||||
}}>
|
||||
<span style={{ color: '#888', fontWeight: 500 }}>{rank}.</span>
|
||||
<span style={{
|
||||
display: 'inline-block',
|
||||
fontSize: '9px', fontWeight: 700, letterSpacing: '0.2px',
|
||||
padding: '1px 4px', borderRadius: '3px',
|
||||
background: tagColor.bg, color: tagColor.fg,
|
||||
border: `1px solid ${tagColor.border}`,
|
||||
whiteSpace: 'nowrap', lineHeight: '1.3',
|
||||
}}>
|
||||
{abbreviation}
|
||||
</span>
|
||||
<span style={{ color: style.color, fontWeight: 700, marginLeft: 'auto' }} aria-label={style.label}>
|
||||
{statusGlyph}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '11px', fontWeight: 600, color: '#333',
|
||||
lineHeight: 1.2,
|
||||
maxHeight: '2.4em',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}>
|
||||
{name}
|
||||
</div>
|
||||
<div style={{
|
||||
position: 'relative', height: '4px', background: '#e5e7eb',
|
||||
borderRadius: '2px', overflow: 'hidden', marginTop: 'auto',
|
||||
}}>
|
||||
{potential > allocated && (
|
||||
<div style={{
|
||||
position: 'absolute', left: 0, top: 0, height: '100%',
|
||||
width: `${potentialPct}%`, background: '#bfdbfe',
|
||||
transition: 'width 300ms ease-out',
|
||||
}} />
|
||||
)}
|
||||
<div style={{
|
||||
position: 'absolute', left: 0, top: 0, height: '100%',
|
||||
width: `${allocPct}%`,
|
||||
background: allocated >= threshold ? '#22c55e' : '#3b82f6',
|
||||
transition: 'width 300ms ease-out',
|
||||
}} />
|
||||
<div style={{
|
||||
position: 'absolute', left: `${thresholdPct}%`, top: '-1px',
|
||||
width: '1px', height: '6px', background: '#666',
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SpecChipPopoverProps {
|
||||
specId: string;
|
||||
name: string;
|
||||
status: SpecStatus;
|
||||
allocated: number;
|
||||
allocations: Record<string, Record<string, number>>;
|
||||
anchorRect: DOMRect;
|
||||
onClose: () => void;
|
||||
onHoverEnter: () => void;
|
||||
onHoverLeave: () => void;
|
||||
}
|
||||
|
||||
function SpecChipPopover({ specId, name, status, allocated, allocations, anchorRect, onClose, onHoverEnter, onHoverLeave }: SpecChipPopoverProps) {
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
const style = STATUS_STYLES[status] || STATUS_STYLES.unreachable;
|
||||
|
||||
useEffect(() => {
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') onClose();
|
||||
}
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
return () => document.removeEventListener('keydown', onKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
function onClickOutside(e: MouseEvent) {
|
||||
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
const id = setTimeout(() => document.addEventListener('mousedown', onClickOutside), 0);
|
||||
return () => {
|
||||
clearTimeout(id);
|
||||
document.removeEventListener('mousedown', onClickOutside);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
const popoverMaxHeight = 260;
|
||||
const popoverWidth = 280;
|
||||
const spaceBelow = window.innerHeight - anchorRect.bottom - 6;
|
||||
const spaceAbove = anchorRect.top - 6;
|
||||
const placeAbove = spaceBelow < Math.min(popoverMaxHeight, 150) && spaceAbove > spaceBelow;
|
||||
const left = Math.max(8, Math.min(anchorRect.left, window.innerWidth - popoverWidth - 8));
|
||||
|
||||
const positionStyle: React.CSSProperties = {
|
||||
position: 'fixed',
|
||||
left,
|
||||
width: popoverWidth,
|
||||
...(placeAbove
|
||||
? { bottom: window.innerHeight - anchorRect.top + 6, maxHeight: Math.min(popoverMaxHeight, spaceAbove) }
|
||||
: { top: anchorRect.bottom + 6, maxHeight: Math.min(popoverMaxHeight, spaceBelow) }),
|
||||
};
|
||||
|
||||
const contributions: { courseName: string; credits: number }[] = [];
|
||||
for (const [courseId, specAlloc] of Object.entries(allocations)) {
|
||||
const credits = specAlloc[specId];
|
||||
if (credits && credits > 0) {
|
||||
const course = courseById[courseId];
|
||||
contributions.push({ courseName: course?.name ?? courseId, credits });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={popoverRef}
|
||||
onMouseEnter={onHoverEnter}
|
||||
onMouseLeave={onHoverLeave}
|
||||
style={{
|
||||
...positionStyle,
|
||||
zIndex: 1000,
|
||||
background: '#fff',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.15)',
|
||||
padding: '12px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '6px',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600, fontSize: '14px', color: '#1e293b' }}>{name}</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{
|
||||
fontSize: '11px', padding: '2px 8px', borderRadius: '10px',
|
||||
background: style.color + '20', color: style.color, fontWeight: 600,
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{style.label}
|
||||
</span>
|
||||
<span style={{ fontSize: '12px', color: '#475569', fontVariantNumeric: 'tabular-nums' }}>
|
||||
{allocated > 0 ? allocated.toFixed(1) : '0'} / 9.0 credits
|
||||
</span>
|
||||
</div>
|
||||
{contributions.length > 0 && (
|
||||
<div style={{ marginTop: '4px', borderTop: '1px solid #f1f5f9', paddingTop: '6px' }}>
|
||||
<div style={{ fontSize: '11px', color: '#94a3b8', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.4px', marginBottom: '4px' }}>
|
||||
Contributing courses
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '2px' }}>
|
||||
{contributions.map((c, i) => (
|
||||
<div key={i} style={{ display: 'flex', justifyContent: 'space-between', fontSize: '12px', color: '#475569' }}>
|
||||
<span style={{ paddingRight: '8px' }}>{c.courseName}</span>
|
||||
<span style={{ fontWeight: 600, fontVariantNumeric: 'tabular-nums' }}>{c.credits.toFixed(1)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ interface TopPlansProps {
|
||||
progress: { iterations: number; iterationsTotal: number } | null;
|
||||
pinnedCourses: Record<string, string | null>;
|
||||
ranking: string[];
|
||||
showAnimatedBar?: boolean;
|
||||
onAdopt: (assignments: Record<string, string>) => void;
|
||||
onPin: (setId: string, courseId: string) => void;
|
||||
onUnpin: (setId: string) => void;
|
||||
@@ -35,7 +36,7 @@ function formatScore(n: number): string {
|
||||
return `${k.toFixed(k >= 100 ? 0 : 1)}k`;
|
||||
}
|
||||
|
||||
export function TopPlans({ plans, partial, loading, progress, pinnedCourses, ranking, onAdopt, onPin, onUnpin }: TopPlansProps) {
|
||||
export function TopPlans({ plans, partial, loading, progress, pinnedCourses, ranking, showAnimatedBar = true, onAdopt, onPin, onUnpin }: TopPlansProps) {
|
||||
const rankWeight = useMemo(() => makePriorityRankWeight(ranking), [ranking]);
|
||||
const visible = plans.filter((p) => p.achievedSpecs.length > 0);
|
||||
|
||||
@@ -67,7 +68,7 @@ export function TopPlans({ plans, partial, loading, progress, pinnedCourses, ran
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{loading && progress && (
|
||||
{showAnimatedBar && loading && progress && (
|
||||
<div style={{ marginBottom: '8px' }}>
|
||||
<div style={{
|
||||
position: 'relative', height: '6px', background: '#e5e7eb',
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ import react from '@vitejs/plugin-react'
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
define: {
|
||||
__APP_VERSION__: JSON.stringify('1.3.3'),
|
||||
__APP_VERSION__: JSON.stringify('1.4.0'),
|
||||
__APP_VERSION_DATE__: JSON.stringify('2026-05-09'),
|
||||
},
|
||||
server: {
|
||||
|
||||
Reference in New Issue
Block a user