UI improvements: responsive layout, unified panels, credit legend

- Add responsive 2-panel layout (mobile single-col, tablet/desktop grid)
- Unify specialization ranking with credit bars, status badges, and
  expandable allocation breakdowns (remove standalone ResultsDashboard)
- Inline decision tree ceiling data on course buttons with spec counts
- Add Clear All button to reset all course selections
- Add collapsible CreditLegend explaining bars, badges, and limits
- Extract ModeComparison and MutualExclusionWarnings to Notifications
- Add useMediaQuery hook with matchMedia-based breakpoint detection
This commit is contained in:
2026-02-28 21:17:50 -05:00
parent 9e00901179
commit f8bab9ee33
18 changed files with 718 additions and 369 deletions

View File

@@ -1,23 +1,31 @@
import { ELECTIVE_SETS } from '../data/electiveSets';
import { coursesBySet } from '../data/lookups';
import type { Term } from '../data/types';
import type { SetAnalysis } from '../solver/decisionTree';
interface CourseSelectionProps {
pinnedCourses: Record<string, string | null>;
treeResults: SetAnalysis[];
treeLoading: boolean;
onPin: (setId: string, courseId: string) => void;
onUnpin: (setId: string) => void;
onClearAll: () => void;
}
function ElectiveSet({
setId,
setName,
pinnedCourseId,
analysis,
loading,
onPin,
onUnpin,
}: {
setId: string;
setName: string;
pinnedCourseId: string | null | undefined;
analysis?: SetAnalysis;
loading: boolean;
onPin: (courseId: string) => void;
onUnpin: () => void;
}) {
@@ -25,6 +33,12 @@ function ElectiveSet({
const isPinned = pinnedCourseId != null;
const pinnedCourse = isPinned ? courses.find((c) => c.id === pinnedCourseId) : null;
// Build a map from courseId to ceiling choice data
const ceilingMap = new Map(
(analysis?.choices ?? []).map((ch) => [ch.courseId, ch]),
);
const hasHighImpact = analysis && analysis.impact > 0;
return (
<div
style={{
@@ -36,17 +50,21 @@ function ElectiveSet({
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' }}>
<h4 style={{ fontSize: '13px', margin: 0, color: '#444' }}>{setName}</h4>
<h4 style={{ fontSize: '13px', margin: 0, color: '#444' }}>
{setName}
{!isPinned && hasHighImpact && (
<span style={{ fontSize: '11px', color: '#d97706', marginLeft: '8px', fontWeight: 400 }}>high impact</span>
)}
{!isPinned && loading && !analysis && (
<span style={{ fontSize: '11px', color: '#888', marginLeft: '8px', fontWeight: 400 }}>analyzing...</span>
)}
</h4>
{isPinned && (
<button
onClick={onUnpin}
style={{
fontSize: '11px',
border: 'none',
background: 'none',
color: '#3b82f6',
cursor: 'pointer',
padding: '2px 4px',
fontSize: '11px', border: 'none', background: 'none',
color: '#3b82f6', cursor: 'pointer', padding: '2px 4px',
}}
>
clear
@@ -59,36 +77,66 @@ function ElectiveSet({
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
{courses.map((course) => (
<button
key={course.id}
onClick={() => onPin(course.id)}
style={{
textAlign: 'left',
padding: '6px 10px',
border: '1px solid #e5e7eb',
borderRadius: '4px',
background: '#fff',
cursor: 'pointer',
fontSize: '13px',
color: '#333',
}}
>
{course.name}
</button>
))}
{courses.map((course) => {
const ceiling = ceilingMap.get(course.id);
return (
<button
key={course.id}
onClick={() => onPin(course.id)}
style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
textAlign: 'left', padding: '6px 10px',
border: '1px solid #e5e7eb', borderRadius: '4px',
background: '#fff', cursor: 'pointer', fontSize: '13px', color: '#333',
gap: '8px',
}}
>
<span style={{ flex: 1 }}>{course.name}</span>
{ceiling && (
<span style={{
fontSize: '11px', whiteSpace: 'nowrap', fontWeight: 600,
color: ceiling.ceilingCount >= 3 ? '#16a34a' : ceiling.ceilingCount >= 2 ? '#2563eb' : '#666',
}}>
{ceiling.ceilingCount} spec{ceiling.ceilingCount !== 1 ? 's' : ''}
{ceiling.ceilingSpecs.length > 0 && (
<span style={{ fontWeight: 400, color: '#888', marginLeft: '3px' }}>
({ceiling.ceilingSpecs.join(', ')})
</span>
)}
</span>
)}
</button>
);
})}
</div>
)}
</div>
);
}
export function CourseSelection({ pinnedCourses, onPin, onUnpin }: CourseSelectionProps) {
export function CourseSelection({ pinnedCourses, treeResults, treeLoading, onPin, onUnpin, onClearAll }: CourseSelectionProps) {
const terms: Term[] = ['Spring', 'Summer', 'Fall'];
const hasPinned = Object.keys(pinnedCourses).length > 0;
// Index tree results by setId for O(1) lookup
const treeBySet = new Map(treeResults.map((a) => [a.setId, a]));
return (
<div>
<h2 style={{ fontSize: '16px', marginBottom: '12px' }}>Course Selection</h2>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
<h2 style={{ fontSize: '16px', margin: 0 }}>Course Selection</h2>
{hasPinned && (
<button
onClick={onClearAll}
style={{
fontSize: '12px', border: 'none', background: 'none',
color: '#ef4444', cursor: 'pointer', padding: '2px 6px',
}}
>
Clear All
</button>
)}
</div>
{terms.map((term) => (
<div key={term} style={{ marginBottom: '16px' }}>
<h3 style={{ fontSize: '13px', color: '#888', marginBottom: '8px', textTransform: 'uppercase', letterSpacing: '0.5px' }}>
@@ -100,6 +148,8 @@ export function CourseSelection({ pinnedCourses, onPin, onUnpin }: CourseSelecti
setId={set.id}
setName={set.name}
pinnedCourseId={pinnedCourses[set.id]}
analysis={treeBySet.get(set.id)}
loading={treeLoading}
onPin={(courseId) => onPin(set.id, courseId)}
onUnpin={() => onUnpin(set.id)}
/>