Implement EMBA Specialization Solver web app

Full React+TypeScript app with LP-based optimization engine,
drag-and-drop specialization ranking (with touch/arrow support),
course selection UI, results dashboard with decision tree, and
two optimization modes (maximize-count, priority-order).
This commit is contained in:
2026-02-28 20:43:00 -05:00
parent e62afa631b
commit 9e00901179
43 changed files with 10098 additions and 0 deletions

View File

@@ -0,0 +1,111 @@
import { ELECTIVE_SETS } from '../data/electiveSets';
import { coursesBySet } from '../data/lookups';
import type { Term } from '../data/types';
interface CourseSelectionProps {
pinnedCourses: Record<string, string | null>;
onPin: (setId: string, courseId: string) => void;
onUnpin: (setId: string) => void;
}
function ElectiveSet({
setId,
setName,
pinnedCourseId,
onPin,
onUnpin,
}: {
setId: string;
setName: string;
pinnedCourseId: string | null | undefined;
onPin: (courseId: string) => void;
onUnpin: () => void;
}) {
const courses = coursesBySet[setId];
const isPinned = pinnedCourseId != null;
const pinnedCourse = isPinned ? courses.find((c) => c.id === pinnedCourseId) : null;
return (
<div
style={{
border: isPinned ? '1px solid #3b82f6' : '1px dashed #ccc',
borderRadius: '8px',
padding: '12px',
marginBottom: '8px',
background: isPinned ? '#eff6ff' : '#fafafa',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' }}>
<h4 style={{ fontSize: '13px', margin: 0, color: '#444' }}>{setName}</h4>
{isPinned && (
<button
onClick={onUnpin}
style={{
fontSize: '11px',
border: 'none',
background: 'none',
color: '#3b82f6',
cursor: 'pointer',
padding: '2px 4px',
}}
>
clear
</button>
)}
</div>
{isPinned ? (
<div style={{ fontSize: '14px', fontWeight: 600, color: '#1e40af' }}>
{pinnedCourse?.name}
</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>
))}
</div>
)}
</div>
);
}
export function CourseSelection({ pinnedCourses, onPin, onUnpin }: CourseSelectionProps) {
const terms: Term[] = ['Spring', 'Summer', 'Fall'];
return (
<div>
<h2 style={{ fontSize: '16px', marginBottom: '12px' }}>Course Selection</h2>
{terms.map((term) => (
<div key={term} style={{ marginBottom: '16px' }}>
<h3 style={{ fontSize: '13px', color: '#888', marginBottom: '8px', textTransform: 'uppercase', letterSpacing: '0.5px' }}>
{term}
</h3>
{ELECTIVE_SETS.filter((s) => s.term === term).map((set) => (
<ElectiveSet
key={set.id}
setId={set.id}
setName={set.name}
pinnedCourseId={pinnedCourses[set.id]}
onPin={(courseId) => onPin(set.id, courseId)}
onUnpin={() => onUnpin(set.id)}
/>
))}
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,55 @@
import type { OptimizationMode } from '../data/types';
interface ModeToggleProps {
mode: OptimizationMode;
onSetMode: (mode: OptimizationMode) => void;
}
export function ModeToggle({ mode, onSetMode }: ModeToggleProps) {
return (
<div style={{ marginBottom: '16px' }}>
<h3 style={{ fontSize: '14px', marginBottom: '8px', color: '#666' }}>Optimization Mode</h3>
<div style={{ display: 'flex', gap: '4px', background: '#f0f0f0', borderRadius: '8px', padding: '4px' }}>
<button
onClick={() => onSetMode('maximize-count')}
style={{
flex: 1,
padding: '8px 12px',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '13px',
fontWeight: mode === 'maximize-count' ? 600 : 400,
background: mode === 'maximize-count' ? '#fff' : 'transparent',
boxShadow: mode === 'maximize-count' ? '0 1px 3px rgba(0,0,0,0.1)' : 'none',
color: mode === 'maximize-count' ? '#111' : '#666',
}}
>
Maximize Count
</button>
<button
onClick={() => onSetMode('priority-order')}
style={{
flex: 1,
padding: '8px 12px',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '13px',
fontWeight: mode === 'priority-order' ? 600 : 400,
background: mode === 'priority-order' ? '#fff' : 'transparent',
boxShadow: mode === 'priority-order' ? '0 1px 3px rgba(0,0,0,0.1)' : 'none',
color: mode === 'priority-order' ? '#111' : '#666',
}}
>
Priority Order
</button>
</div>
<p style={{ fontSize: '11px', color: '#888', marginTop: '6px' }}>
{mode === 'maximize-count'
? 'Get as many specializations as possible. Ranking breaks ties.'
: 'Guarantee your top-ranked specialization first, then add more.'}
</p>
</div>
);
}

View File

@@ -0,0 +1,279 @@
import { useState } from 'react';
import { SPECIALIZATIONS } from '../data/specializations';
import { courseById } from '../data/lookups';
import type { AllocationResult, SpecStatus } from '../data/types';
import type { SetAnalysis } from '../solver/decisionTree';
const STATUS_STYLES: Record<SpecStatus, { bg: string; color: string; label: string }> = {
achieved: { bg: '#dcfce7', color: '#16a34a', label: 'Achieved' },
achievable: { bg: '#dbeafe', color: '#2563eb', label: 'Achievable' },
missing_required: { bg: '#fef3c7', color: '#d97706', label: 'Missing Req.' },
unreachable: { bg: '#f3f4f6', color: '#9ca3af', label: 'Unreachable' },
};
interface ResultsDashboardProps {
ranking: string[];
result: AllocationResult;
treeResults: SetAnalysis[];
treeLoading: boolean;
altResult?: AllocationResult; // from the other mode
altModeName?: string;
pinnedCourses?: Record<string, string | null>;
}
function CreditBar({ allocated, potential, threshold }: { allocated: number; potential: number; threshold: number }) {
const maxWidth = Math.max(potential, threshold);
const allocPct = Math.min((allocated / maxWidth) * 100, 100);
const potentialPct = Math.min((potential / maxWidth) * 100, 100);
return (
<div style={{ position: 'relative', height: '6px', background: '#e5e7eb', borderRadius: '3px', marginTop: '4px' }}>
{potential > allocated && (
<div
style={{
position: 'absolute', left: 0, top: 0, height: '100%',
width: `${potentialPct}%`, background: '#bfdbfe', borderRadius: '3px',
}}
/>
)}
<div
style={{
position: 'absolute', left: 0, top: 0, height: '100%',
width: `${allocPct}%`, background: allocated >= threshold ? '#22c55e' : '#3b82f6',
borderRadius: '3px',
}}
/>
<div
style={{
position: 'absolute', left: `${(threshold / maxWidth) * 100}%`, top: '-2px',
width: '2px', height: '10px', background: '#666',
}}
/>
</div>
);
}
function AllocationBreakdown({ specId, allocations }: { specId: string; allocations: Record<string, Record<string, number>> }) {
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 });
}
}
if (contributions.length === 0) return null;
return (
<div style={{ marginTop: '8px', paddingLeft: '28px', fontSize: '12px', color: '#555' }}>
{contributions.map((c, i) => (
<div key={i} style={{ display: 'flex', justifyContent: 'space-between', padding: '2px 0' }}>
<span>{c.courseName}</span>
<span style={{ fontWeight: 600 }}>{c.credits.toFixed(1)}</span>
</div>
))}
</div>
);
}
function DecisionTree({ analyses, loading }: { analyses: SetAnalysis[]; loading: boolean }) {
if (analyses.length === 0 && !loading) return null;
return (
<div style={{ marginTop: '20px' }}>
<h3 style={{ fontSize: '14px', marginBottom: '8px' }}>
Decision Tree {loading && <span style={{ fontSize: '12px', color: '#888' }}>(computing...)</span>}
</h3>
{analyses.map((a) => (
<div key={a.setId} style={{ marginBottom: '12px', border: '1px solid #e5e7eb', borderRadius: '6px', padding: '10px' }}>
<div style={{ fontSize: '13px', fontWeight: 600, marginBottom: '6px' }}>
{a.setName}
{a.impact > 0 && (
<span style={{ fontSize: '11px', color: '#d97706', marginLeft: '8px' }}>high impact</span>
)}
</div>
{a.choices.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
{a.choices.map((choice) => (
<div
key={choice.courseId}
style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
padding: '4px 8px', background: '#f9fafb', borderRadius: '4px', fontSize: '12px',
}}
>
<span>{choice.courseName}</span>
<span style={{ fontWeight: 600, color: choice.ceilingCount >= 3 ? '#16a34a' : choice.ceilingCount >= 2 ? '#2563eb' : '#666' }}>
{choice.ceilingCount} spec{choice.ceilingCount !== 1 ? 's' : ''}
{choice.ceilingSpecs.length > 0 && (
<span style={{ fontWeight: 400, color: '#888', marginLeft: '4px' }}>
({choice.ceilingSpecs.join(', ')})
</span>
)}
</span>
</div>
))}
</div>
) : (
<div style={{ fontSize: '12px', color: '#888' }}>Awaiting analysis...</div>
)}
</div>
))}
</div>
);
}
function ModeComparison({
result,
altResult,
altModeName,
}: {
result: AllocationResult;
altResult: AllocationResult;
altModeName: string;
}) {
const currentSpecs = new Set(result.achieved);
const altSpecs = new Set(altResult.achieved);
if (
currentSpecs.size === altSpecs.size &&
result.achieved.every((s) => altSpecs.has(s))
) {
return null; // Modes agree
}
return (
<div
style={{
background: '#fef3c7', border: '1px solid #fcd34d', borderRadius: '6px',
padding: '10px', marginBottom: '12px', fontSize: '12px',
}}
>
<strong>Mode comparison:</strong> {altModeName} achieves {altResult.achieved.length} specialization
{altResult.achieved.length !== 1 ? 's' : ''} ({altResult.achieved.join(', ') || 'none'}) vs. current{' '}
{result.achieved.length} ({result.achieved.join(', ') || 'none'}).
</div>
);
}
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: '12px' }}>
{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>
);
}
export function ResultsDashboard({
ranking,
result,
treeResults,
treeLoading,
altResult,
altModeName,
pinnedCourses,
}: ResultsDashboardProps) {
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const specMap = new Map(SPECIALIZATIONS.map((s) => [s.id, s]));
function toggleExpand(specId: string) {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(specId)) next.delete(specId);
else next.add(specId);
return next;
});
}
// Compute per-spec allocated credits
function getAllocatedCredits(specId: string): number {
let total = 0;
for (const specAlloc of Object.values(result.allocations)) {
total += specAlloc[specId] || 0;
}
return total;
}
return (
<div>
<h2 style={{ fontSize: '16px', marginBottom: '12px' }}>Results</h2>
{altResult && altModeName && (
<ModeComparison result={result} altResult={altResult} altModeName={altModeName} />
)}
<MutualExclusionWarnings pinnedCourses={pinnedCourses} />
<div style={{ marginBottom: '8px', fontSize: '13px', color: '#666' }}>
{result.achieved.length > 0
? `${result.achieved.length} specialization${result.achieved.length > 1 ? 's' : ''} achieved`
: 'No specializations achieved yet'}
</div>
{ranking.map((specId) => {
const spec = specMap.get(specId);
if (!spec) return null;
const status = result.statuses[specId];
const style = STATUS_STYLES[status] || STATUS_STYLES.unreachable;
const allocated = getAllocatedCredits(specId);
const potential = result.upperBounds[specId] || 0;
const isAchieved = status === 'achieved';
return (
<div key={specId} style={{ marginBottom: '6px' }}>
<div
onClick={() => isAchieved && toggleExpand(specId)}
style={{
display: 'flex', alignItems: 'center', gap: '8px',
padding: '8px 12px', borderRadius: '6px',
background: style.bg, cursor: isAchieved ? 'pointer' : 'default',
}}
>
<span style={{ flex: 1, fontSize: '13px', color: '#333' }}>{spec.name}</span>
<span style={{ fontSize: '11px', color: '#888' }}>
{allocated > 0 ? `${allocated.toFixed(1)}` : '0'} / 9.0
</span>
<span
style={{
fontSize: '11px', padding: '2px 8px', borderRadius: '10px',
background: style.color + '15', color: style.color, fontWeight: 600,
}}
>
{style.label}
</span>
</div>
<CreditBar allocated={allocated} potential={potential} threshold={9} />
{isAchieved && expanded.has(specId) && (
<AllocationBreakdown specId={specId} allocations={result.allocations} />
)}
</div>
);
})}
<DecisionTree analyses={treeResults} loading={treeLoading} />
</div>
);
}

View File

@@ -0,0 +1,152 @@
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
TouchSensor,
useSensor,
useSensors,
type DragEndEvent,
} from '@dnd-kit/core';
import {
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
arrayMove,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { SPECIALIZATIONS } from '../data/specializations';
import type { SpecStatus } from '../data/types';
interface SortableItemProps {
id: string;
rank: number;
total: number;
name: string;
status?: SpecStatus;
onMoveUp: () => void;
onMoveDown: () => void;
}
function SortableItem({ id, rank, total, name, status, onMoveUp, onMoveDown }: SortableItemProps) {
const {
attributes,
listeners,
setNodeRef,
setActivatorNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id });
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
display: 'flex',
alignItems: 'center',
gap: '6px',
padding: '6px 10px',
marginBottom: '4px',
borderRadius: '6px',
background: isDragging ? '#e8e8e8' : '#fff',
border: '1px solid #ddd',
fontSize: '14px',
};
const statusColors: Record<SpecStatus, string> = {
achieved: '#22c55e',
achievable: '#3b82f6',
missing_required: '#f59e0b',
unreachable: '#9ca3af',
};
const arrowBtn: React.CSSProperties = {
border: 'none',
background: 'none',
cursor: 'pointer',
padding: '0 2px',
fontSize: '14px',
color: '#999',
lineHeight: 1,
};
return (
<div ref={setNodeRef} style={style} {...attributes}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0px' }}>
<button style={{ ...arrowBtn, visibility: rank === 1 ? 'hidden' : 'visible' }} onClick={onMoveUp} aria-label="Move up"></button>
<button style={{ ...arrowBtn, visibility: rank === total ? 'hidden' : 'visible' }} onClick={onMoveDown} aria-label="Move down"></button>
</div>
<span
ref={setActivatorNodeRef}
{...listeners}
style={{ cursor: 'grab', color: '#ccc', fontSize: '14px', touchAction: 'none' }}
aria-label="Drag to reorder"
></span>
<span style={{ color: '#999', fontSize: '12px', width: '20px' }}>{rank}.</span>
<span style={{ flex: 1 }}>{name}</span>
{status && (
<span
style={{
fontSize: '11px',
padding: '2px 6px',
borderRadius: '10px',
background: statusColors[status] + '20',
color: statusColors[status],
fontWeight: 600,
}}
>
{status === 'missing_required' ? 'missing req.' : status}
</span>
)}
</div>
);
}
interface SpecializationRankingProps {
ranking: string[];
statuses: Record<string, SpecStatus>;
onReorder: (ranking: string[]) => void;
}
export function SpecializationRanking({ ranking, statuses, onReorder }: SpecializationRankingProps) {
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(TouchSensor, { activationConstraint: { delay: 150, tolerance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event;
if (over && active.id !== over.id) {
const oldIndex = ranking.indexOf(active.id as string);
const newIndex = ranking.indexOf(over.id as string);
onReorder(arrayMove(ranking, oldIndex, newIndex));
}
}
const specMap = new Map(SPECIALIZATIONS.map((s) => [s.id, s]));
return (
<div>
<h2 style={{ fontSize: '16px', marginBottom: '12px' }}>Specialization Priority</h2>
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={ranking} strategy={verticalListSortingStrategy}>
{ranking.map((id, i) => (
<SortableItem
key={id}
id={id}
rank={i + 1}
total={ranking.length}
name={specMap.get(id)?.name ?? id}
status={statuses[id]}
onMoveUp={() => { if (i > 0) onReorder(arrayMove([...ranking], i, i - 1)); }}
onMoveDown={() => { if (i < ranking.length - 1) onReorder(arrayMove([...ranking], i, i + 1)); }}
/>
))}
</SortableContext>
</DndContext>
</div>
);
}