Show required specialization labels on course buttons

Replace top-level mutual exclusion banner with dynamic per-course
"Required for ..." labels derived from specialization data. Labels
appear on any course that is a specialization prerequisite, across
all elective sets.
This commit is contained in:
2026-02-28 21:33:54 -05:00
parent f8bab9ee33
commit 6af24d9270
3 changed files with 31 additions and 46 deletions

View File

@@ -5,7 +5,7 @@ import { SpecializationRanking } from './components/SpecializationRanking';
import { ModeToggle } from './components/ModeToggle'; 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 { ModeComparison, MutualExclusionWarnings } from './components/Notifications'; import { ModeComparison } from './components/Notifications';
import { optimize } from './solver/optimizer'; import { optimize } from './solver/optimizer';
function App() { function App() {
@@ -53,7 +53,6 @@ function App() {
<ModeToggle mode={state.mode} onSetMode={setMode} /> <ModeToggle mode={state.mode} onSetMode={setMode} />
<MutualExclusionWarnings pinnedCourses={state.pinnedCourses} />
<ModeComparison <ModeComparison
result={optimizationResult} result={optimizationResult}
altResult={altResult} altResult={altResult}

View File

@@ -1,8 +1,17 @@
import { ELECTIVE_SETS } from '../data/electiveSets'; import { ELECTIVE_SETS } from '../data/electiveSets';
import { SPECIALIZATIONS } from '../data/specializations';
import { coursesBySet } from '../data/lookups'; import { coursesBySet } from '../data/lookups';
import type { Term } from '../data/types'; import type { Term } from '../data/types';
import type { SetAnalysis } from '../solver/decisionTree'; import type { SetAnalysis } from '../solver/decisionTree';
// Reverse map: courseId → specialization names that require it
const requiredForSpec: Record<string, string[]> = {};
for (const spec of SPECIALIZATIONS) {
if (spec.requiredCourseId) {
(requiredForSpec[spec.requiredCourseId] ??= []).push(spec.name);
}
}
interface CourseSelectionProps { interface CourseSelectionProps {
pinnedCourses: Record<string, string | null>; pinnedCourses: Record<string, string | null>;
treeResults: SetAnalysis[]; treeResults: SetAnalysis[];
@@ -79,30 +88,37 @@ function ElectiveSet({
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
{courses.map((course) => { {courses.map((course) => {
const ceiling = ceilingMap.get(course.id); const ceiling = ceilingMap.get(course.id);
const reqFor = requiredForSpec[course.id];
return ( return (
<button <button
key={course.id} key={course.id}
onClick={() => onPin(course.id)} onClick={() => onPin(course.id)}
style={{ style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center', display: 'flex', flexDirection: 'column', alignItems: 'stretch',
textAlign: 'left', padding: '6px 10px', textAlign: 'left', padding: '6px 10px',
border: '1px solid #e5e7eb', borderRadius: '4px', border: '1px solid #e5e7eb', borderRadius: '4px',
background: '#fff', cursor: 'pointer', fontSize: '13px', color: '#333', background: '#fff', cursor: 'pointer', fontSize: '13px', color: '#333',
gap: '8px',
}} }}
> >
<span style={{ flex: 1 }}>{course.name}</span> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '8px' }}>
{ceiling && ( <span style={{ flex: 1 }}>{course.name}</span>
<span style={{ {ceiling && (
fontSize: '11px', whiteSpace: 'nowrap', fontWeight: 600, <span style={{
color: ceiling.ceilingCount >= 3 ? '#16a34a' : ceiling.ceilingCount >= 2 ? '#2563eb' : '#666', 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 && ( {ceiling.ceilingCount} spec{ceiling.ceilingCount !== 1 ? 's' : ''}
<span style={{ fontWeight: 400, color: '#888', marginLeft: '3px' }}> {ceiling.ceilingSpecs.length > 0 && (
({ceiling.ceilingSpecs.join(', ')}) <span style={{ fontWeight: 400, color: '#888', marginLeft: '3px' }}>
</span> ({ceiling.ceilingSpecs.join(', ')})
)} </span>
)}
</span>
)}
</div>
{reqFor && (
<span style={{ fontSize: '11px', color: '#92400e', marginTop: '2px' }}>
Required for {reqFor.join(', ')}
</span> </span>
)} )}
</button> </button>

View File

@@ -33,33 +33,3 @@ export function ModeComparison({
); );
} }
export function MutualExclusionWarnings({ pinnedCourses }: { pinnedCourses: Record<string, string | null> }) {
const warnings: string[] = [];
const spr4Pin = pinnedCourses['spr4'];
if (!spr4Pin) {
warnings.push('Spring Set 4: choosing Sustainability for Competitive Advantage eliminates Entrepreneurship & Innovation (and vice versa).');
} else if (spr4Pin === 'spr4-sustainability') {
warnings.push('Entrepreneurship & Innovation is permanently unavailable (required course is in Spring Set 4, pinned to Sustainability).');
} else if (spr4Pin === 'spr4-foundations-entrepreneurship') {
warnings.push('Sustainable Business & Innovation is permanently unavailable (required course is in Spring Set 4, pinned to Foundations of Entrepreneurship).');
}
if (warnings.length === 0) return null;
return (
<div style={{ marginBottom: '8px' }}>
{warnings.map((w, i) => (
<div
key={i}
style={{
background: '#fef3c7', border: '1px solid #fcd34d', borderRadius: '6px',
padding: '8px 10px', fontSize: '12px', color: '#92400e', marginBottom: '4px',
}}
>
{w}
</div>
))}
</div>
);
}