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

24
app/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

73
app/README.md Normal file
View File

@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

23
app/eslint.config.js Normal file
View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

13
app/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>app</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

6834
app/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

38
app/package.json Normal file
View File

@@ -0,0 +1,38 @@
{
"name": "app",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"javascript-lp-solver": "^1.0.3",
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"agent-browser": "^0.15.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.48.0",
"vite": "^7.3.1",
"vitest": "^4.0.18"
}
}

1
app/public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

0
app/src/App.css Normal file
View File

67
app/src/App.tsx Normal file
View File

@@ -0,0 +1,67 @@
import { useMemo } from 'react';
import { useAppState } from './state/appState';
import { SpecializationRanking } from './components/SpecializationRanking';
import { ModeToggle } from './components/ModeToggle';
import { CourseSelection } from './components/CourseSelection';
import { ResultsDashboard } from './components/ResultsDashboard';
import { optimize } from './solver/optimizer';
function App() {
const {
state,
optimizationResult,
treeResults,
treeLoading,
openSetIds,
selectedCourseIds,
reorder,
setMode,
pinCourse,
unpinCourse,
} = useAppState();
// Compute alternative mode result for comparison
const altMode = state.mode === 'maximize-count' ? 'priority-order' : 'maximize-count';
const altResult = useMemo(
() => optimize(selectedCourseIds, state.ranking, openSetIds, altMode),
[selectedCourseIds, state.ranking, openSetIds, altMode],
);
return (
<div style={{ maxWidth: '1200px', margin: '0 auto', padding: '20px', fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif' }}>
<h1 style={{ fontSize: '20px', marginBottom: '20px', color: '#111' }}>
EMBA Specialization Solver
</h1>
<div style={{ display: 'grid', gridTemplateColumns: '280px 1fr 1fr', gap: '24px', alignItems: 'start' }}>
<div>
<ModeToggle mode={state.mode} onSetMode={setMode} />
<SpecializationRanking
ranking={state.ranking}
statuses={optimizationResult.statuses}
onReorder={reorder}
/>
</div>
<div style={{ maxHeight: '85vh', overflowY: 'auto' }}>
<CourseSelection
pinnedCourses={state.pinnedCourses}
onPin={pinCourse}
onUnpin={unpinCourse}
/>
</div>
<div style={{ maxHeight: '85vh', overflowY: 'auto' }}>
<ResultsDashboard
ranking={state.ranking}
result={optimizationResult}
treeResults={treeResults}
treeLoading={treeLoading}
altResult={altResult}
altModeName={altMode === 'maximize-count' ? 'Maximize Count' : 'Priority Order'}
pinnedCourses={state.pinnedCourses}
/>
</div>
</div>
</div>
);
}
export default App;

1
app/src/assets/react.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

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>
);
}

View File

@@ -0,0 +1,110 @@
import { describe, it, expect } from 'vitest';
import { COURSES } from '../courses';
import { ELECTIVE_SETS } from '../electiveSets';
import { SPECIALIZATIONS } from '../specializations';
import { coursesBySet, coursesBySpec } from '../lookups';
describe('Data integrity', () => {
it('has exactly 46 courses', () => {
expect(COURSES.length).toBe(46);
});
it('has exactly 12 elective sets', () => {
expect(ELECTIVE_SETS.length).toBe(12);
});
it('has exactly 14 specializations', () => {
expect(SPECIALIZATIONS.length).toBe(14);
});
it('every course belongs to a valid set and that set references the course', () => {
for (const course of COURSES) {
const set = ELECTIVE_SETS.find((s) => s.id === course.setId);
expect(set, `Course ${course.id} references invalid set ${course.setId}`).toBeDefined();
expect(set!.courseIds).toContain(course.id);
}
});
it('every set course ID references a valid course', () => {
for (const set of ELECTIVE_SETS) {
for (const cid of set.courseIds) {
const course = COURSES.find((c) => c.id === cid);
expect(course, `Set ${set.id} references invalid course ${cid}`).toBeDefined();
}
}
});
it('has exactly 4 required course gates', () => {
const withRequired = SPECIALIZATIONS.filter((s) => s.requiredCourseId);
expect(withRequired.length).toBe(4);
const mapping: Record<string, string> = {};
for (const s of withRequired) {
mapping[s.abbreviation] = s.requiredCourseId!;
}
expect(mapping).toEqual({
SBI: 'spr4-sustainability',
ENT: 'spr4-foundations-entrepreneurship',
EMT: 'sum3-entertainment-media',
BRM: 'fall4-brand-strategy',
});
});
it('has exactly 10 S1 markers and 7 S2 markers for Strategy', () => {
let s1Count = 0;
let s2Count = 0;
for (const course of COURSES) {
for (const q of course.qualifications) {
if (q.specId === 'STR' && q.marker === 'S1') s1Count++;
if (q.specId === 'STR' && q.marker === 'S2') s2Count++;
}
}
expect(s1Count).toBe(10);
expect(s2Count).toBe(7);
});
it('all qualification markers are valid types', () => {
for (const course of COURSES) {
for (const q of course.qualifications) {
expect(['standard', 'S1', 'S2']).toContain(q.marker);
}
}
});
it('Spring Set 1 and Summer Set 1 contain the same three courses', () => {
const spr1Names = coursesBySet['spr1'].map((c) => c.name).sort();
const sum1Names = coursesBySet['sum1'].map((c) => c.name).sort();
expect(spr1Names).toEqual(sum1Names);
});
describe('per-specialization "across sets" counts match reachability table', () => {
// Expected counts: number of distinct sets that have at least one qualifying course
const expectedAcrossSets: Record<string, number> = {
MGT: 11,
STR: 9,
LCM: 9,
FIN: 9,
CRF: 8,
MKT: 7,
BNK: 6,
BRM: 6,
FIM: 6,
MTO: 6,
GLB: 5,
EMT: 4,
ENT: 4,
SBI: 4,
};
for (const [specId, expected] of Object.entries(expectedAcrossSets)) {
it(`${specId} qualifies across ${expected} sets`, () => {
const entries = coursesBySpec[specId] || [];
const courseIds = entries.map((e) => e.courseId);
const setIds = new Set(
courseIds.map((cid) => COURSES.find((c) => c.id === cid)!.setId)
);
expect(setIds.size).toBe(expected);
});
}
});
});

247
app/src/data/courses.ts Normal file
View File

@@ -0,0 +1,247 @@
import type { Course } from './types';
export const COURSES: Course[] = [
// === Spring Elective Set 1 ===
{
id: 'spr1-global-immersion', name: 'Global Immersion Experience II', setId: 'spr1',
qualifications: [{ specId: 'GLB', marker: 'standard' }],
},
{
id: 'spr1-collaboration', name: 'Collaboration, Conflict and Negotiation', setId: 'spr1',
qualifications: [{ specId: 'LCM', marker: 'standard' }, { specId: 'MGT', marker: 'standard' }],
},
{
id: 'spr1-high-stakes', name: 'Conquering High Stakes Communication', setId: 'spr1',
qualifications: [{ specId: 'LCM', marker: 'standard' }],
},
// === Spring Elective Set 2 ===
{
id: 'spr2-consumer-behavior', name: 'Consumer Behavior', setId: 'spr2',
qualifications: [{ specId: 'BRM', marker: 'standard' }, { specId: 'MKT', marker: 'standard' }],
},
{
id: 'spr2-health-medical', name: 'The Business of Health & Medical Care', setId: 'spr2',
qualifications: [{ specId: 'STR', marker: 'S2' }],
},
{
id: 'spr2-human-rights', name: 'Human Rights and Business', setId: 'spr2',
qualifications: [{ specId: 'GLB', marker: 'standard' }, { specId: 'SBI', marker: 'standard' }],
},
{
id: 'spr2-financial-services', name: 'The Financial Services Industry', setId: 'spr2',
qualifications: [
{ specId: 'BNK', marker: 'standard' }, { specId: 'CRF', marker: 'standard' },
{ specId: 'FIN', marker: 'standard' }, { specId: 'FIM', marker: 'standard' },
],
},
// === Spring Elective Set 3 ===
{
id: 'spr3-mergers-acquisitions', name: 'Mergers & Acquisitions', setId: 'spr3',
qualifications: [
{ specId: 'CRF', marker: 'standard' }, { specId: 'FIN', marker: 'standard' },
{ specId: 'LCM', marker: 'standard' }, { specId: 'STR', marker: 'S1' },
],
},
{
id: 'spr3-digital-strategy', name: 'Digital Strategy', setId: 'spr3',
qualifications: [{ specId: 'EMT', marker: 'standard' }, { specId: 'MTO', marker: 'standard' }, { specId: 'STR', marker: 'S2' }],
},
{
id: 'spr3-managing-high-tech', name: 'Managing a High Tech Company: The CEO Perspective', setId: 'spr3',
qualifications: [{ specId: 'EMT', marker: 'standard' }, { specId: 'MGT', marker: 'standard' }, { specId: 'MTO', marker: 'standard' }, { specId: 'STR', marker: 'S2' }],
},
{
id: 'spr3-analytics-ml', name: 'Analytics & Machine Learning for Managers', setId: 'spr3',
qualifications: [{ specId: 'MTO', marker: 'standard' }],
},
// === Spring Elective Set 4 ===
{
id: 'spr4-fintech', name: 'Foundations of Fintech', setId: 'spr4',
qualifications: [{ specId: 'FIN', marker: 'standard' }],
},
{
id: 'spr4-sustainability', name: 'Sustainability for Competitive Advantage', setId: 'spr4',
qualifications: [{ specId: 'LCM', marker: 'standard' }, { specId: 'SBI', marker: 'standard' }, { specId: 'STR', marker: 'S1' }],
},
{
id: 'spr4-pricing', name: 'Pricing', setId: 'spr4',
qualifications: [{ specId: 'BRM', marker: 'standard' }, { specId: 'MKT', marker: 'standard' }, { specId: 'MTO', marker: 'standard' }],
},
{
id: 'spr4-foundations-entrepreneurship', name: 'Foundations of Entrepreneurship', setId: 'spr4',
qualifications: [{ specId: 'ENT', marker: 'standard' }, { specId: 'MGT', marker: 'standard' }, { specId: 'STR', marker: 'S1' }],
},
// === Spring Elective Set 5 ===
{
id: 'spr5-corporate-finance', name: 'Corporate Finance', setId: 'spr5',
qualifications: [{ specId: 'CRF', marker: 'standard' }, { specId: 'FIN', marker: 'standard' }],
},
{
id: 'spr5-consulting-practice', name: 'Consulting Practice: Process and Problem Solving', setId: 'spr5',
qualifications: [{ specId: 'MGT', marker: 'standard' }, { specId: 'STR', marker: 'S1' }],
},
{
id: 'spr5-global-strategy', name: 'Global Strategy', setId: 'spr5',
qualifications: [{ specId: 'GLB', marker: 'standard' }, { specId: 'MGT', marker: 'standard' }, { specId: 'STR', marker: 'S1' }],
},
{
id: 'spr5-customer-insights', name: 'Customer Insights', setId: 'spr5',
qualifications: [{ specId: 'BRM', marker: 'standard' }, { specId: 'MKT', marker: 'standard' }],
},
// === Summer Elective Set 1 ===
{
id: 'sum1-global-immersion', name: 'Global Immersion Experience II', setId: 'sum1',
qualifications: [{ specId: 'GLB', marker: 'standard' }],
},
{
id: 'sum1-collaboration', name: 'Collaboration, Conflict and Negotiation', setId: 'sum1',
qualifications: [{ specId: 'LCM', marker: 'standard' }, { specId: 'MGT', marker: 'standard' }],
},
{
id: 'sum1-high-stakes', name: 'Conquering High Stakes Communication', setId: 'sum1',
qualifications: [{ specId: 'LCM', marker: 'standard' }],
},
// === Summer Elective Set 2 ===
{
id: 'sum2-managing-growing', name: 'Managing Growing Companies', setId: 'sum2',
qualifications: [
{ specId: 'ENT', marker: 'standard' }, { specId: 'LCM', marker: 'standard' },
{ specId: 'MGT', marker: 'standard' }, { specId: 'STR', marker: 'S1' },
],
},
{
id: 'sum2-social-media', name: 'Social Media and Mobile Technology', setId: 'sum2',
qualifications: [{ specId: 'BRM', marker: 'standard' }, { specId: 'EMT', marker: 'standard' }, { specId: 'MKT', marker: 'standard' }],
},
{
id: 'sum2-leading-ai', name: 'Leading in the Age of AI', setId: 'sum2',
qualifications: [{ specId: 'LCM', marker: 'standard' }, { specId: 'MGT', marker: 'standard' }, { specId: 'MTO', marker: 'standard' }],
},
{
id: 'sum2-business-drivers', name: 'Business Drivers', setId: 'sum2',
qualifications: [{ specId: 'STR', marker: 'S1' }],
},
// === Summer Elective Set 3 ===
{
id: 'sum3-valuation', name: 'Valuation', setId: 'sum3',
qualifications: [
{ specId: 'BNK', marker: 'standard' }, { specId: 'CRF', marker: 'standard' },
{ specId: 'FIN', marker: 'standard' }, { specId: 'FIM', marker: 'standard' },
],
},
{
id: 'sum3-entertainment-media', name: 'Entertainment and Media Industries', setId: 'sum3',
qualifications: [{ specId: 'EMT', marker: 'standard' }, { specId: 'MKT', marker: 'standard' }, { specId: 'STR', marker: 'S2' }],
},
{
id: 'sum3-advanced-corporate-strategy', name: 'Advanced Corporate Strategy', setId: 'sum3',
qualifications: [{ specId: 'MGT', marker: 'standard' }, { specId: 'STR', marker: 'S1' }],
},
{
id: 'sum3-power-influence', name: 'Power and Professional Influence', setId: 'sum3',
qualifications: [{ specId: 'LCM', marker: 'standard' }, { specId: 'MGT', marker: 'standard' }],
},
// === Fall Elective Set 1 ===
{
id: 'fall1-operations-strategy', name: 'Operations Strategy', setId: 'fall1',
qualifications: [{ specId: 'MTO', marker: 'standard' }],
},
{
id: 'fall1-private-equity', name: 'Private Equity', setId: 'fall1',
qualifications: [
{ specId: 'BNK', marker: 'standard' }, { specId: 'CRF', marker: 'standard' },
{ specId: 'FIN', marker: 'standard' }, { specId: 'FIM', marker: 'standard' },
{ specId: 'STR', marker: 'S2' },
],
},
{
id: 'fall1-managing-change', name: 'Managing Change', setId: 'fall1',
qualifications: [{ specId: 'LCM', marker: 'standard' }, { specId: 'MGT', marker: 'standard' }, { specId: 'STR', marker: 'S2' }],
},
{
id: 'fall1-social-entrepreneurship', name: 'Social Entrepreneurship', setId: 'fall1',
qualifications: [{ specId: 'ENT', marker: 'standard' }, { specId: 'MGT', marker: 'standard' }, { specId: 'SBI', marker: 'standard' }],
},
// === Fall Elective Set 2 ===
{
id: 'fall2-real-estate', name: 'Real Estate Investment Strategy', setId: 'fall2',
qualifications: [{ specId: 'FIN', marker: 'standard' }, { specId: 'FIM', marker: 'standard' }],
},
{
id: 'fall2-decision-models', name: 'Decision Models and Analytics', setId: 'fall2',
qualifications: [{ specId: 'MGT', marker: 'standard' }, { specId: 'MTO', marker: 'standard' }],
},
{
id: 'fall2-behavioral-finance', name: 'Behavioral Finance and Market Psychology', setId: 'fall2',
qualifications: [
{ specId: 'BNK', marker: 'standard' }, { specId: 'CRF', marker: 'standard' },
{ specId: 'FIN', marker: 'standard' }, { specId: 'FIM', marker: 'standard' },
],
},
{
id: 'fall2-crisis-management', name: 'Crisis Management', setId: 'fall2',
qualifications: [{ specId: 'LCM', marker: 'standard' }, { specId: 'MGT', marker: 'standard' }],
},
// === Fall Elective Set 3 ===
{
id: 'fall3-corporate-governance', name: 'Corporate Governance', setId: 'fall3',
qualifications: [{ specId: 'LCM', marker: 'standard' }, { specId: 'MGT', marker: 'standard' }, { specId: 'SBI', marker: 'standard' }, { specId: 'STR', marker: 'S1' }],
},
{
id: 'fall3-climate-finance', name: 'Climate Finance', setId: 'fall3',
qualifications: [
{ specId: 'BNK', marker: 'standard' }, { specId: 'CRF', marker: 'standard' },
{ specId: 'FIN', marker: 'standard' }, { specId: 'FIM', marker: 'standard' },
{ specId: 'GLB', marker: 'standard' }, { specId: 'SBI', marker: 'standard' },
],
},
{
id: 'fall3-emerging-tech', name: 'Emerging Tech and Business Innovation', setId: 'fall3',
qualifications: [
{ specId: 'BRM', marker: 'standard' }, { specId: 'EMT', marker: 'standard' },
{ specId: 'ENT', marker: 'standard' }, { specId: 'MTO', marker: 'standard' },
{ specId: 'STR', marker: 'S2' },
],
},
{
id: 'fall3-tech-innovation-media', name: 'Technology, Innovation, and Disruption in Media', setId: 'fall3',
qualifications: [
{ specId: 'BRM', marker: 'standard' }, { specId: 'EMT', marker: 'standard' },
{ specId: 'MKT', marker: 'standard' }, { specId: 'MTO', marker: 'standard' },
],
},
// === Fall Elective Set 4 ===
{
id: 'fall4-turnaround', name: 'Turnaround, Restructuring and Distressed Investments', setId: 'fall4',
qualifications: [
{ specId: 'BNK', marker: 'standard' }, { specId: 'CRF', marker: 'standard' },
{ specId: 'FIN', marker: 'standard' }, { specId: 'FIM', marker: 'standard' },
],
},
{
id: 'fall4-financial-services', name: 'The Financial Services Industry', setId: 'fall4',
qualifications: [
{ specId: 'BNK', marker: 'standard' }, { specId: 'CRF', marker: 'standard' },
{ specId: 'FIN', marker: 'standard' }, { specId: 'FIM', marker: 'standard' },
],
},
{
id: 'fall4-game-theory', name: 'Game Theory', setId: 'fall4',
qualifications: [{ specId: 'MGT', marker: 'standard' }, { specId: 'STR', marker: 'S1' }],
},
{
id: 'fall4-brand-strategy', name: 'Brand Strategy', setId: 'fall4',
qualifications: [{ specId: 'BRM', marker: 'standard' }, { specId: 'MKT', marker: 'standard' }],
},
];

View File

@@ -0,0 +1,52 @@
import type { ElectiveSet } from './types';
export const ELECTIVE_SETS: ElectiveSet[] = [
{
id: 'spr1', name: 'Spring Elective Set 1', term: 'Spring',
courseIds: ['spr1-global-immersion', 'spr1-collaboration', 'spr1-high-stakes'],
},
{
id: 'spr2', name: 'Spring Elective Set 2', term: 'Spring',
courseIds: ['spr2-consumer-behavior', 'spr2-health-medical', 'spr2-human-rights', 'spr2-financial-services'],
},
{
id: 'spr3', name: 'Spring Elective Set 3', term: 'Spring',
courseIds: ['spr3-mergers-acquisitions', 'spr3-digital-strategy', 'spr3-managing-high-tech', 'spr3-analytics-ml'],
},
{
id: 'spr4', name: 'Spring Elective Set 4', term: 'Spring',
courseIds: ['spr4-fintech', 'spr4-sustainability', 'spr4-pricing', 'spr4-foundations-entrepreneurship'],
},
{
id: 'spr5', name: 'Spring Elective Set 5', term: 'Spring',
courseIds: ['spr5-corporate-finance', 'spr5-consulting-practice', 'spr5-global-strategy', 'spr5-customer-insights'],
},
{
id: 'sum1', name: 'Summer Elective Set 1', term: 'Summer',
courseIds: ['sum1-global-immersion', 'sum1-collaboration', 'sum1-high-stakes'],
},
{
id: 'sum2', name: 'Summer Elective Set 2', term: 'Summer',
courseIds: ['sum2-managing-growing', 'sum2-social-media', 'sum2-leading-ai', 'sum2-business-drivers'],
},
{
id: 'sum3', name: 'Summer Elective Set 3', term: 'Summer',
courseIds: ['sum3-valuation', 'sum3-entertainment-media', 'sum3-advanced-corporate-strategy', 'sum3-power-influence'],
},
{
id: 'fall1', name: 'Fall Elective Set 1', term: 'Fall',
courseIds: ['fall1-operations-strategy', 'fall1-private-equity', 'fall1-managing-change', 'fall1-social-entrepreneurship'],
},
{
id: 'fall2', name: 'Fall Elective Set 2', term: 'Fall',
courseIds: ['fall2-real-estate', 'fall2-decision-models', 'fall2-behavioral-finance', 'fall2-crisis-management'],
},
{
id: 'fall3', name: 'Fall Elective Set 3', term: 'Fall',
courseIds: ['fall3-corporate-governance', 'fall3-climate-finance', 'fall3-emerging-tech', 'fall3-tech-innovation-media'],
},
{
id: 'fall4', name: 'Fall Elective Set 4', term: 'Fall',
courseIds: ['fall4-turnaround', 'fall4-financial-services', 'fall4-game-theory', 'fall4-brand-strategy'],
},
];

40
app/src/data/lookups.ts Normal file
View File

@@ -0,0 +1,40 @@
import { COURSES } from './courses';
import { ELECTIVE_SETS } from './electiveSets';
import type { Course, Qualification } from './types';
// Courses indexed by set ID
export const coursesBySet: Record<string, Course[]> = {};
for (const set of ELECTIVE_SETS) {
coursesBySet[set.id] = set.courseIds.map(
(cid) => COURSES.find((c) => c.id === cid)!
);
}
// Qualifications indexed by course ID
export const qualificationsByCourse: Record<string, Qualification[]> = {};
for (const course of COURSES) {
qualificationsByCourse[course.id] = course.qualifications;
}
// Course IDs indexed by specialization ID (with marker info)
export const coursesBySpec: Record<string, { courseId: string; marker: Qualification['marker'] }[]> = {};
for (const course of COURSES) {
for (const q of course.qualifications) {
if (!coursesBySpec[q.specId]) {
coursesBySpec[q.specId] = [];
}
coursesBySpec[q.specId].push({ courseId: course.id, marker: q.marker });
}
}
// Course lookup by ID
export const courseById: Record<string, Course> = {};
for (const course of COURSES) {
courseById[course.id] = course;
}
// Set ID lookup by course ID
export const setIdByCourse: Record<string, string> = {};
for (const course of COURSES) {
setIdByCourse[course.id] = course.setId;
}

View File

@@ -0,0 +1,18 @@
import type { Specialization } from './types';
export const SPECIALIZATIONS: Specialization[] = [
{ id: 'BNK', name: 'Banking', abbreviation: 'BNK' },
{ id: 'BRM', name: 'Brand Management', abbreviation: 'BRM', requiredCourseId: 'fall4-brand-strategy' },
{ id: 'CRF', name: 'Corporate Finance', abbreviation: 'CRF' },
{ id: 'EMT', name: 'Entertainment, Media, and Technology', abbreviation: 'EMT', requiredCourseId: 'sum3-entertainment-media' },
{ id: 'ENT', name: 'Entrepreneurship and Innovation', abbreviation: 'ENT', requiredCourseId: 'spr4-foundations-entrepreneurship' },
{ id: 'FIN', name: 'Finance', abbreviation: 'FIN' },
{ id: 'FIM', name: 'Financial Instruments and Markets', abbreviation: 'FIM' },
{ id: 'GLB', name: 'Global Business', abbreviation: 'GLB' },
{ id: 'LCM', name: 'Leadership and Change Management', abbreviation: 'LCM' },
{ id: 'MGT', name: 'Management', abbreviation: 'MGT' },
{ id: 'MKT', name: 'Marketing', abbreviation: 'MKT' },
{ id: 'MTO', name: 'Management of Technology and Operations', abbreviation: 'MTO' },
{ id: 'SBI', name: 'Sustainable Business and Innovation', abbreviation: 'SBI', requiredCourseId: 'spr4-sustainability' },
{ id: 'STR', name: 'Strategy', abbreviation: 'STR' },
];

40
app/src/data/types.ts Normal file
View File

@@ -0,0 +1,40 @@
export type Term = 'Spring' | 'Summer' | 'Fall';
export type MarkerType = 'standard' | 'S1' | 'S2';
export interface ElectiveSet {
id: string;
name: string;
term: Term;
courseIds: string[];
}
export interface Qualification {
specId: string;
marker: MarkerType;
}
export interface Course {
id: string;
name: string;
setId: string;
qualifications: Qualification[];
}
export interface Specialization {
id: string;
name: string;
abbreviation: string;
requiredCourseId?: string;
}
export type SpecStatus = 'achieved' | 'achievable' | 'missing_required' | 'unreachable';
export interface AllocationResult {
achieved: string[];
allocations: Record<string, Record<string, number>>; // courseId -> specId -> credits
statuses: Record<string, SpecStatus>;
upperBounds: Record<string, number>;
}
export type OptimizationMode = 'maximize-count' | 'priority-order';

29
app/src/index.css Normal file
View File

@@ -0,0 +1,29 @@
:root {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.5;
font-weight: 400;
color: #111;
background-color: #fff;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 960px;
min-height: 100vh;
}
h1, h2, h3, h4 {
margin: 0;
}
button {
font-family: inherit;
}

10
app/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@@ -0,0 +1,91 @@
import { describe, it, expect } from 'vitest';
import { analyzeDecisionTree } from '../decisionTree';
import { SPECIALIZATIONS } from '../../data/specializations';
const allSpecIds = SPECIALIZATIONS.map((s) => s.id);
describe('analyzeDecisionTree', () => {
it('returns empty choices when too many open sets (fallback)', () => {
const openSets = ['spr1', 'spr2', 'spr3', 'spr4', 'spr5', 'sum1', 'sum2', 'sum3', 'fall1', 'fall2'];
const analyses = analyzeDecisionTree([], openSets, allSpecIds, 'maximize-count');
expect(analyses.length).toBe(10);
for (const a of analyses) {
expect(a.choices.length).toBe(0); // fallback: no enumeration
}
});
it('computes ceiling outcomes for a small number of open sets', () => {
// Pin most sets, leave 2 open
const pinned = [
'spr1-collaboration',
'spr2-financial-services',
'spr3-mergers-acquisitions',
'spr4-fintech',
'spr5-corporate-finance',
'sum1-collaboration',
'sum2-managing-growing',
'sum3-valuation',
'fall1-private-equity',
'fall2-behavioral-finance',
];
const openSets = ['fall3', 'fall4'];
const analyses = analyzeDecisionTree(pinned, openSets, allSpecIds, 'maximize-count');
expect(analyses.length).toBe(2);
for (const a of analyses) {
expect(a.choices.length).toBeGreaterThan(0);
for (const choice of a.choices) {
expect(choice.ceilingCount).toBeGreaterThanOrEqual(0);
expect(choice.ceilingCount).toBeLessThanOrEqual(3);
expect(choice.courseName).toBeTruthy();
}
}
});
it('orders sets by impact (highest variance first)', () => {
const pinned = [
'spr1-collaboration',
'spr2-financial-services',
'spr3-mergers-acquisitions',
'spr4-fintech',
'spr5-corporate-finance',
'sum1-collaboration',
'sum2-managing-growing',
'sum3-valuation',
'fall1-private-equity',
'fall2-behavioral-finance',
];
const openSets = ['fall3', 'fall4'];
const analyses = analyzeDecisionTree(pinned, openSets, allSpecIds, 'maximize-count');
// First set should have impact >= second set's impact
if (analyses.length === 2) {
expect(analyses[0].impact).toBeGreaterThanOrEqual(analyses[1].impact);
}
});
it('calls onSetComplete progressively', () => {
const pinned = [
'spr1-collaboration',
'spr2-financial-services',
'spr3-mergers-acquisitions',
'spr4-fintech',
'spr5-corporate-finance',
'sum1-collaboration',
'sum2-managing-growing',
'sum3-valuation',
'fall1-private-equity',
'fall2-behavioral-finance',
];
const openSets = ['fall3', 'fall4'];
const completed: string[] = [];
analyzeDecisionTree(pinned, openSets, allSpecIds, 'maximize-count', (analysis) => {
completed.push(analysis.setId);
});
expect(completed).toContain('fall3');
expect(completed).toContain('fall4');
expect(completed.length).toBe(2);
});
});

View File

@@ -0,0 +1,173 @@
import { describe, it, expect } from 'vitest';
import {
checkFeasibility,
preFilterCandidates,
enumerateS2Choices,
computeUpperBounds,
} from '../feasibility';
describe('checkFeasibility', () => {
it('returns feasible for empty target set', () => {
const result = checkFeasibility([], []);
expect(result.feasible).toBe(true);
});
it('returns infeasible when no courses qualify for target spec', () => {
// Only 1 course qualifying for GLB (2.5 credits) — need 9
const result = checkFeasibility(['spr1-global-immersion'], ['GLB']);
expect(result.feasible).toBe(false);
});
it('returns feasible with enough qualifying courses for a single spec', () => {
// Finance-qualifying courses from different sets
const finCourses = [
'spr2-financial-services', // FIN ■
'spr3-mergers-acquisitions', // FIN ■
'spr5-corporate-finance', // FIN ■
'sum3-valuation', // FIN ■
];
const result = checkFeasibility(finCourses, ['FIN']);
expect(result.feasible).toBe(true);
// Check allocations sum to at least 9 for FIN
let finTotal = 0;
for (const courseAlloc of Object.values(result.allocations)) {
finTotal += courseAlloc['FIN'] || 0;
}
expect(finTotal).toBeGreaterThanOrEqual(9);
});
it('returns feasible for two specs when credits are sufficient', () => {
// Need 18 total credits (9 FIN + 9 CRF). 8 courses × 2.5 = 20 available.
const courses = [
'spr2-financial-services', // CRF FIN (+ BNK FIM)
'spr3-mergers-acquisitions', // CRF FIN (+ LCM STR)
'spr4-fintech', // FIN only
'spr5-corporate-finance', // CRF FIN
'sum3-valuation', // CRF FIN (+ BNK FIM)
'fall1-private-equity', // CRF FIN (+ BNK FIM)
'fall2-behavioral-finance', // CRF FIN (+ BNK FIM)
'fall3-climate-finance', // CRF FIN (+ BNK FIM GLB SBI)
];
const result = checkFeasibility(courses, ['FIN', 'CRF']);
expect(result.feasible).toBe(true);
});
it('enforces course capacity constraint (2.5 max per course)', () => {
const result = checkFeasibility(
['spr2-financial-services', 'spr3-mergers-acquisitions', 'spr5-corporate-finance', 'sum3-valuation', 'fall1-private-equity', 'fall2-behavioral-finance'],
['FIN', 'CRF'],
);
if (result.feasible) {
for (const [courseId, specAlloc] of Object.entries(result.allocations)) {
const total = Object.values(specAlloc).reduce((a, b) => a + b, 0);
expect(total).toBeLessThanOrEqual(2.5 + 0.001); // float tolerance
}
}
});
it('returns infeasible when 3 specs need more than 30 available credits', () => {
// Only 3 courses (7.5 credits), trying to achieve 3 specs (27 credits needed)
const result = checkFeasibility(
['spr2-financial-services', 'sum3-valuation', 'fall2-behavioral-finance'],
['FIN', 'CRF', 'BNK'],
);
expect(result.feasible).toBe(false);
});
});
describe('checkFeasibility with S2 constraint', () => {
it('respects s2Choice — only chosen S2 course contributes to Strategy', () => {
// Courses with Strategy S1 and S2 markers
const courses = [
'spr3-mergers-acquisitions', // STR S1
'spr3-digital-strategy', // STR S2 — we'll choose this
'spr5-consulting-practice', // STR S1
'sum3-advanced-corporate-strategy', // STR S1
'fall3-corporate-governance', // STR S1
];
// With s2Choice = 'spr3-digital-strategy', it should contribute
const result = checkFeasibility(courses, ['STR'], 'spr3-digital-strategy');
expect(result.feasible).toBe(true);
});
it('without s2Choice, S2 courses do not contribute to Strategy', () => {
// Only S2 courses for Strategy — none should count with s2Choice=null
const courses = [
'spr3-digital-strategy', // STR S2
'fall1-private-equity', // STR S2
];
const result = checkFeasibility(courses, ['STR'], null);
expect(result.feasible).toBe(false);
});
});
describe('preFilterCandidates', () => {
it('excludes specs whose required course is in a pinned set with different selection', () => {
// spr4 is pinned to fintech (not sustainability or entrepreneurship)
const selected = ['spr4-fintech'];
const openSets = ['spr1', 'spr2', 'spr3', 'spr5', 'sum1', 'sum2', 'sum3', 'fall1', 'fall2', 'fall3', 'fall4'];
const candidates = preFilterCandidates(selected, openSets);
// SBI requires spr4-sustainability, ENT requires spr4-foundations-entrepreneurship
// Both are in spr4 which is pinned, so neither should be a candidate
expect(candidates).not.toContain('SBI');
expect(candidates).not.toContain('ENT');
});
it('includes specs whose required course is selected', () => {
const selected = ['fall4-brand-strategy'];
const openSets = ['spr1', 'spr2', 'spr3', 'spr4', 'spr5', 'sum1', 'sum2', 'sum3', 'fall1', 'fall2', 'fall3'];
const candidates = preFilterCandidates(selected, openSets);
expect(candidates).toContain('BRM');
});
it('includes specs whose required course is in an open set', () => {
const selected: string[] = [];
const openSets = ['spr1', 'spr2', 'spr3', 'spr4', 'spr5', 'sum1', 'sum2', 'sum3', 'fall1', 'fall2', 'fall3', 'fall4'];
const candidates = preFilterCandidates(selected, openSets);
// All specs with required courses should be candidates when their sets are open
expect(candidates).toContain('SBI');
expect(candidates).toContain('ENT');
expect(candidates).toContain('EMT');
expect(candidates).toContain('BRM');
});
});
describe('enumerateS2Choices', () => {
it('returns [null] when no S2 courses are selected', () => {
const choices = enumerateS2Choices(['spr1-global-immersion']);
expect(choices).toEqual([null]);
});
it('returns null plus each selected S2 course', () => {
const choices = enumerateS2Choices([
'spr3-digital-strategy', // S2
'fall1-private-equity', // S2
'spr1-global-immersion', // not S2
]);
expect(choices).toContain(null);
expect(choices).toContain('spr3-digital-strategy');
expect(choices).toContain('fall1-private-equity');
expect(choices.length).toBe(3);
});
});
describe('computeUpperBounds', () => {
it('counts selected courses and open sets for each spec', () => {
const bounds = computeUpperBounds(
['spr1-global-immersion'], // GLB
['spr5', 'fall3'], // spr5 has Global Strategy (GLB), fall3 has Climate Finance (GLB)
);
// spr1 selected (GLB) + 2 open sets with GLB courses = 7.5
expect(bounds['GLB']).toBe(7.5);
});
it('does not double-count sets', () => {
const bounds = computeUpperBounds(
['spr2-financial-services'], // BNK, CRF, FIN, FIM in set spr2
[],
);
// FIN: only 1 course selected from 1 set
expect(bounds['FIN']).toBe(2.5);
});
});

View File

@@ -0,0 +1,134 @@
import { describe, it, expect } from 'vitest';
import { maximizeCount, priorityOrder, determineStatuses, optimize } from '../optimizer';
import { SPECIALIZATIONS } from '../../data/specializations';
const allSpecIds = SPECIALIZATIONS.map((s) => s.id);
// A finance-heavy selection that should achieve finance specs
const financeHeavyCourses = [
'spr2-financial-services', // BNK CRF FIN FIM
'spr3-mergers-acquisitions', // CRF FIN LCM STR(S1)
'spr4-fintech', // FIN
'spr5-corporate-finance', // CRF FIN
'sum3-valuation', // BNK CRF FIN FIM
'fall1-private-equity', // BNK CRF FIN FIM STR(S2)
'fall2-behavioral-finance', // BNK CRF FIN FIM
'fall3-climate-finance', // BNK CRF FIN FIM GLB SBI
'fall4-financial-services', // BNK CRF FIN FIM
];
describe('maximizeCount', () => {
it('returns empty achieved when no courses are selected', () => {
const result = maximizeCount([], allSpecIds, []);
expect(result.achieved).toEqual([]);
});
it('achieves specs with enough qualifying courses', () => {
const result = maximizeCount(financeHeavyCourses, allSpecIds, []);
expect(result.achieved.length).toBeGreaterThan(0);
// Should be able to achieve some combo of FIN, CRF, BNK, FIM
for (const specId of result.achieved) {
expect(['BNK', 'CRF', 'FIN', 'FIM', 'LCM', 'GLB', 'SBI', 'STR']).toContain(specId);
}
});
it('prefers higher-priority specs when breaking ties', () => {
// Put FIM first in ranking
const ranking = ['FIM', ...allSpecIds.filter((id) => id !== 'FIM')];
const result1 = maximizeCount(financeHeavyCourses, ranking, []);
// Put BNK first
const ranking2 = ['BNK', ...allSpecIds.filter((id) => id !== 'BNK')];
const result2 = maximizeCount(financeHeavyCourses, ranking2, []);
// Both should achieve the same count
expect(result1.achieved.length).toBe(result2.achieved.length);
// If they achieve multiple, the first-ranked spec should appear when possible
if (result1.achieved.length > 0) {
// The highest-priority feasible spec should be in the result
expect(result1.achieved).toContain('FIM');
}
});
it('never exceeds 3 specializations', () => {
const result = maximizeCount(financeHeavyCourses, allSpecIds, []);
expect(result.achieved.length).toBeLessThanOrEqual(3);
});
});
describe('priorityOrder', () => {
it('guarantees the top-ranked feasible spec', () => {
const ranking = ['FIN', ...allSpecIds.filter((id) => id !== 'FIN')];
const result = priorityOrder(financeHeavyCourses, ranking, []);
expect(result.achieved[0]).toBe('FIN');
});
it('skips infeasible specs and continues', () => {
// GLB has very few qualifying courses in this set
const ranking = ['GLB', 'FIN', 'CRF', ...allSpecIds.filter((id) => !['GLB', 'FIN', 'CRF'].includes(id))];
const result = priorityOrder(financeHeavyCourses, ranking, []);
// GLB might not be achievable on its own, but FIN should be
expect(result.achieved).toContain('FIN');
});
it('returns empty when no courses are selected', () => {
const result = priorityOrder([], allSpecIds, []);
expect(result.achieved).toEqual([]);
});
});
describe('determineStatuses', () => {
it('marks achieved specs correctly', () => {
const statuses = determineStatuses(financeHeavyCourses, [], ['FIN', 'CRF']);
expect(statuses['FIN']).toBe('achieved');
expect(statuses['CRF']).toBe('achieved');
});
it('marks missing_required when required course set is pinned to different course', () => {
// spr4-fintech selected (not sustainability or entrepreneurship)
const statuses = determineStatuses(['spr4-fintech'], [], []);
expect(statuses['SBI']).toBe('missing_required');
expect(statuses['ENT']).toBe('missing_required');
});
it('marks achievable when required course is in open set', () => {
const statuses = determineStatuses(
[],
['spr1', 'spr2', 'spr3', 'spr4', 'spr5', 'sum1', 'sum2', 'sum3', 'fall1', 'fall2', 'fall3', 'fall4'],
[],
);
// All specs with enough potential should be achievable
expect(statuses['FIN']).toBe('achievable');
expect(statuses['MGT']).toBe('achievable');
});
it('marks unreachable when upper bound is below threshold', () => {
// Only 1 set open, most specs won't have enough potential
const statuses = determineStatuses([], ['spr1'], []);
// Most specs only have 1 qualifying course in spr1 (2.5 credits < 9)
expect(statuses['FIN']).toBe('unreachable');
});
});
describe('optimize (integration)', () => {
it('returns a complete AllocationResult', () => {
const result = optimize(financeHeavyCourses, allSpecIds, [], 'maximize-count');
expect(result.achieved).toBeDefined();
expect(result.allocations).toBeDefined();
expect(result.statuses).toBeDefined();
expect(result.upperBounds).toBeDefined();
expect(Object.keys(result.statuses).length).toBe(14);
});
it('modes can produce different results', () => {
// Put a niche spec first in priority order
const ranking = ['EMT', ...allSpecIds.filter((id) => id !== 'EMT')];
const maxResult = optimize(financeHeavyCourses, ranking, [], 'maximize-count');
const prioResult = optimize(financeHeavyCourses, ranking, [], 'priority-order');
// Both should produce valid results
expect(maxResult.achieved.length).toBeGreaterThanOrEqual(0);
expect(prioResult.achieved.length).toBeGreaterThanOrEqual(0);
});
});

View File

@@ -0,0 +1,141 @@
import { ELECTIVE_SETS } from '../data/electiveSets';
import { coursesBySet } from '../data/lookups';
import type { OptimizationMode } from '../data/types';
import { maximizeCount, priorityOrder } from './optimizer';
export interface ChoiceOutcome {
courseId: string;
courseName: string;
ceilingCount: number;
ceilingSpecs: string[];
}
export interface SetAnalysis {
setId: string;
setName: string;
impact: number; // variance in ceiling outcomes
choices: ChoiceOutcome[];
}
const MAX_OPEN_SETS_FOR_ENUMERATION = 9;
/**
* Compute the ceiling outcome for a single course choice:
* the best achievable result assuming that course is pinned
* and all other open sets are chosen optimally.
*/
function computeCeiling(
basePinnedCourses: string[],
chosenCourseId: string,
otherOpenSetIds: string[],
ranking: string[],
mode: OptimizationMode,
): { count: number; specs: string[] } {
const fn = mode === 'maximize-count' ? maximizeCount : priorityOrder;
if (otherOpenSetIds.length === 0) {
// No other open sets — just solve with this choice added
const selected = [...basePinnedCourses, chosenCourseId];
const result = fn(selected, ranking, []);
return { count: result.achieved.length, specs: result.achieved };
}
// Enumerate all combinations of remaining open sets
let bestCount = 0;
let bestSpecs: string[] = [];
function enumerate(setIndex: number, accumulated: string[]) {
// Early termination: already found max (3)
if (bestCount >= 3) return;
if (setIndex >= otherOpenSetIds.length) {
const selected = [...basePinnedCourses, chosenCourseId, ...accumulated];
const result = fn(selected, ranking, []);
if (result.achieved.length > bestCount) {
bestCount = result.achieved.length;
bestSpecs = result.achieved;
}
return;
}
const setId = otherOpenSetIds[setIndex];
const courses = coursesBySet[setId];
for (const course of courses) {
enumerate(setIndex + 1, [...accumulated, course.id]);
if (bestCount >= 3) return;
}
}
enumerate(0, []);
return { count: bestCount, specs: bestSpecs };
}
/**
* Compute variance of an array of numbers.
*/
function variance(values: number[]): number {
if (values.length <= 1) return 0;
const mean = values.reduce((a, b) => a + b, 0) / values.length;
return values.reduce((sum, v) => sum + (v - mean) ** 2, 0) / values.length;
}
/**
* Analyze all open sets and compute per-choice ceiling outcomes.
* Returns sets ordered by decision impact (highest first).
*
* onSetComplete is called progressively as each set's analysis finishes.
*/
export function analyzeDecisionTree(
pinnedCourseIds: string[],
openSetIds: string[],
ranking: string[],
mode: OptimizationMode,
onSetComplete?: (analysis: SetAnalysis) => void,
): SetAnalysis[] {
if (openSetIds.length > MAX_OPEN_SETS_FOR_ENUMERATION) {
// Fallback: return empty analyses (caller uses upper bounds instead)
return openSetIds.map((setId) => {
const set = ELECTIVE_SETS.find((s) => s.id === setId)!;
return { setId, setName: set.name, impact: 0, choices: [] };
});
}
const analyses: SetAnalysis[] = [];
for (const setId of openSetIds) {
const set = ELECTIVE_SETS.find((s) => s.id === setId)!;
const otherOpenSets = openSetIds.filter((id) => id !== setId);
const courses = coursesBySet[setId];
const choices: ChoiceOutcome[] = courses.map((course) => {
const ceiling = computeCeiling(
pinnedCourseIds,
course.id,
otherOpenSets,
ranking,
mode,
);
return {
courseId: course.id,
courseName: course.name,
ceilingCount: ceiling.count,
ceilingSpecs: ceiling.specs,
};
});
const impact = variance(choices.map((c) => c.ceilingCount));
const analysis: SetAnalysis = { setId, setName: set.name, impact, choices };
analyses.push(analysis);
onSetComplete?.(analysis);
}
// Sort by impact descending, then by set order (chronological) for ties
const setOrder = new Map(ELECTIVE_SETS.map((s, i) => [s.id, i]));
analyses.sort((a, b) => {
if (b.impact !== a.impact) return b.impact - a.impact;
return (setOrder.get(a.setId) ?? 0) - (setOrder.get(b.setId) ?? 0);
});
return analyses;
}

View File

@@ -0,0 +1,193 @@
import solver from 'javascript-lp-solver';
import { COURSES } from '../data/courses';
import { SPECIALIZATIONS } from '../data/specializations';
import { coursesBySpec, setIdByCourse } from '../data/lookups';
import type { MarkerType } from '../data/types';
const CREDIT_PER_COURSE = 2.5;
const CREDIT_THRESHOLD = 9;
export interface FeasibilityResult {
feasible: boolean;
allocations: Record<string, Record<string, number>>; // courseId -> specId -> credits
}
/**
* Check whether a target set of specializations can each reach 9 credits
* given a fixed set of selected courses.
*
* When Strategy is in targetSpecs, s2Choice controls which S2 course (if any)
* may contribute credits to Strategy.
*/
export function checkFeasibility(
selectedCourseIds: string[],
targetSpecIds: string[],
s2Choice: string | null = null,
): FeasibilityResult {
if (targetSpecIds.length === 0) {
return { feasible: true, allocations: {} };
}
// Build the set of valid (course, spec) pairs
const selectedSet = new Set(selectedCourseIds);
const targetSet = new Set(targetSpecIds);
// Build LP model
const variables: Record<string, Record<string, number>> = {};
const constraints: Record<string, { max?: number; min?: number }> = {};
// For each selected course, add a capacity constraint: sum of allocations <= 2.5
for (const courseId of selectedCourseIds) {
const course = COURSES.find((c) => c.id === courseId)!;
const capacityKey = `cap_${courseId}`;
constraints[capacityKey] = { max: CREDIT_PER_COURSE };
for (const q of course.qualifications) {
if (!targetSet.has(q.specId)) continue;
// Strategy S2 constraint: only the chosen S2 course can contribute to Strategy
if (q.specId === 'STR' && q.marker === 'S2') {
if (s2Choice !== courseId) continue;
}
const varName = `x_${courseId}_${q.specId}`;
variables[varName] = {
[capacityKey]: 1,
[`need_${q.specId}`]: 1,
_dummy: 0, // need an optimize target
};
}
}
// For each target spec, add a demand constraint: sum of allocations >= 9
for (const specId of targetSpecIds) {
constraints[`need_${specId}`] = { min: CREDIT_THRESHOLD };
}
// If no variables were created, it's infeasible
if (Object.keys(variables).length === 0) {
return { feasible: false, allocations: {} };
}
const model = {
optimize: '_dummy',
opType: 'max' as const,
constraints,
variables,
};
const result = solver.Solve(model);
if (!result.feasible) {
return { feasible: false, allocations: {} };
}
// Extract allocations from result
const allocations: Record<string, Record<string, number>> = {};
for (const key of Object.keys(result)) {
if (!key.startsWith('x_')) continue;
const val = result[key] as number;
if (val <= 0) continue;
const parts = key.split('_');
// key format: x_<courseId>_<specId>
// courseId may contain hyphens, specId is always 3 chars at the end
const specId = parts[parts.length - 1];
const courseId = parts.slice(1, -1).join('_');
if (!allocations[courseId]) allocations[courseId] = {};
allocations[courseId][specId] = val;
}
return { feasible: true, allocations };
}
/**
* Pre-filter specializations to only those that could potentially be achieved.
* Removes specs whose required course is not selected and not available in open sets.
*/
export function preFilterCandidates(
selectedCourseIds: string[],
openSetIds: string[],
): string[] {
const selectedSet = new Set(selectedCourseIds);
const openSetSet = new Set(openSetIds);
return SPECIALIZATIONS.filter((spec) => {
// Check required course gate
if (spec.requiredCourseId) {
if (!selectedSet.has(spec.requiredCourseId)) {
// Is the required course available in an open set?
const requiredCourse = COURSES.find((c) => c.id === spec.requiredCourseId)!;
if (!openSetSet.has(requiredCourse.setId)) {
return false; // required course's set is pinned to something else
}
}
}
// Check upper-bound credit potential
const entries = coursesBySpec[spec.id] || [];
let potential = 0;
const countedSets = new Set<string>();
for (const e of entries) {
const setId = setIdByCourse[e.courseId];
if (selectedSet.has(e.courseId)) {
if (!countedSets.has(setId)) {
potential += CREDIT_PER_COURSE;
countedSets.add(setId);
}
} else if (openSetSet.has(setId) && !countedSets.has(setId)) {
potential += CREDIT_PER_COURSE;
countedSets.add(setId);
}
}
return potential >= CREDIT_THRESHOLD;
}).map((s) => s.id);
}
/**
* Return the list of S2 course options for Strategy enumeration.
* Includes null (no S2 course contributes).
*/
export function enumerateS2Choices(selectedCourseIds: string[]): (string | null)[] {
const selectedSet = new Set(selectedCourseIds);
const s2Courses = COURSES.filter(
(c) =>
selectedSet.has(c.id) &&
c.qualifications.some((q) => q.specId === 'STR' && q.marker === 'S2'),
).map((c) => c.id);
return [null, ...s2Courses];
}
/**
* Compute upper-bound credit potential per specialization.
* Ignores credit sharing — used only for reachability status determination.
*/
export function computeUpperBounds(
selectedCourseIds: string[],
openSetIds: string[],
): Record<string, number> {
const selectedSet = new Set(selectedCourseIds);
const openSetSet = new Set(openSetIds);
const bounds: Record<string, number> = {};
for (const spec of SPECIALIZATIONS) {
const entries = coursesBySpec[spec.id] || [];
let potential = 0;
const countedSets = new Set<string>();
for (const e of entries) {
const setId = setIdByCourse[e.courseId];
if (selectedSet.has(e.courseId)) {
if (!countedSets.has(setId)) {
potential += CREDIT_PER_COURSE;
countedSets.add(setId);
}
} else if (openSetSet.has(setId) && !countedSets.has(setId)) {
potential += CREDIT_PER_COURSE;
countedSets.add(setId);
}
}
bounds[spec.id] = potential;
}
return bounds;
}

199
app/src/solver/optimizer.ts Normal file
View File

@@ -0,0 +1,199 @@
import { SPECIALIZATIONS } from '../data/specializations';
import { COURSES } from '../data/courses';
import { coursesBySpec, setIdByCourse } from '../data/lookups';
import type { AllocationResult, SpecStatus } from '../data/types';
import {
checkFeasibility,
enumerateS2Choices,
preFilterCandidates,
computeUpperBounds,
} from './feasibility';
const CREDIT_THRESHOLD = 9;
const CREDIT_PER_COURSE = 2.5;
/**
* Generate all combinations of k items from arr.
*/
function combinations<T>(arr: T[], k: number): T[][] {
if (k === 0) return [[]];
if (k > arr.length) return [];
const result: T[][] = [];
for (let i = 0; i <= arr.length - k; i++) {
for (const rest of combinations(arr.slice(i + 1), k - 1)) {
result.push([arr[i], ...rest]);
}
}
return result;
}
/**
* Try to check feasibility for a target set, handling S2 enumeration for Strategy.
*/
function checkWithS2(
selectedCourseIds: string[],
targetSpecIds: string[],
): { feasible: boolean; allocations: Record<string, Record<string, number>> } {
const hasStrategy = targetSpecIds.includes('STR');
if (!hasStrategy) {
return checkFeasibility(selectedCourseIds, targetSpecIds);
}
// Enumerate S2 choices
const s2Choices = enumerateS2Choices(selectedCourseIds);
for (const s2Choice of s2Choices) {
const result = checkFeasibility(selectedCourseIds, targetSpecIds, s2Choice);
if (result.feasible) return result;
}
return { feasible: false, allocations: {} };
}
/**
* Maximize Count mode: find the largest set of achievable specializations.
* Among equal-size feasible subsets, prefer the one with the highest priority score.
*/
export function maximizeCount(
selectedCourseIds: string[],
ranking: string[],
openSetIds: string[],
): { achieved: string[]; allocations: Record<string, Record<string, number>> } {
const candidates = preFilterCandidates(selectedCourseIds, openSetIds);
// Only check specs that can be achieved from selected courses alone (not open sets)
// Filter to candidates that have qualifying selected courses
const achievable = candidates.filter((specId) => {
const entries = coursesBySpec[specId] || [];
return entries.some((e) => selectedCourseIds.includes(e.courseId));
});
// Priority score: sum of (15 - rank position) for each spec in subset
const rankIndex = new Map(ranking.map((id, i) => [id, i]));
function priorityScore(specs: string[]): number {
return specs.reduce((sum, id) => sum + (15 - (rankIndex.get(id) ?? 14)), 0);
}
// Try from size 3 down to 0
const maxSize = Math.min(3, achievable.length);
for (let size = maxSize; size >= 1; size--) {
const subsets = combinations(achievable, size);
// Sort subsets by priority score descending — check best-scoring first
subsets.sort((a, b) => priorityScore(b) - priorityScore(a));
let bestResult: { achieved: string[]; allocations: Record<string, Record<string, number>> } | null = null;
let bestScore = -1;
for (const subset of subsets) {
const result = checkWithS2(selectedCourseIds, subset);
if (result.feasible) {
const score = priorityScore(subset);
if (score > bestScore) {
bestScore = score;
bestResult = { achieved: subset, allocations: result.allocations };
}
}
}
if (bestResult) return bestResult;
}
return { achieved: [], allocations: {} };
}
/**
* Priority Order mode: process specializations in rank order,
* adding each if feasible with the current achieved set.
*/
export function priorityOrder(
selectedCourseIds: string[],
ranking: string[],
openSetIds: string[],
): { achieved: string[]; allocations: Record<string, Record<string, number>> } {
const candidates = new Set(preFilterCandidates(selectedCourseIds, openSetIds));
// Only consider specs that have qualifying selected courses
const withSelectedCourses = new Set(
SPECIALIZATIONS.filter((spec) => {
const entries = coursesBySpec[spec.id] || [];
return entries.some((e) => selectedCourseIds.includes(e.courseId));
}).map((s) => s.id),
);
const achieved: string[] = [];
let lastAllocations: Record<string, Record<string, number>> = {};
for (const specId of ranking) {
if (!candidates.has(specId)) continue;
if (!withSelectedCourses.has(specId)) continue;
if (achieved.length >= 3) break;
const trySet = [...achieved, specId];
const result = checkWithS2(selectedCourseIds, trySet);
if (result.feasible) {
achieved.push(specId);
lastAllocations = result.allocations;
}
}
return { achieved, allocations: lastAllocations };
}
/**
* Determine the status of each specialization after optimization.
*/
export function determineStatuses(
selectedCourseIds: string[],
openSetIds: string[],
achieved: string[],
): Record<string, SpecStatus> {
const achievedSet = new Set(achieved);
const selectedSet = new Set(selectedCourseIds);
const openSetSet = new Set(openSetIds);
const upperBounds = computeUpperBounds(selectedCourseIds, openSetIds);
const statuses: Record<string, SpecStatus> = {};
for (const spec of SPECIALIZATIONS) {
if (achievedSet.has(spec.id)) {
statuses[spec.id] = 'achieved';
continue;
}
// Check required course gate
if (spec.requiredCourseId) {
if (!selectedSet.has(spec.requiredCourseId)) {
const requiredCourse = COURSES.find((c) => c.id === spec.requiredCourseId)!;
if (!openSetSet.has(requiredCourse.setId)) {
statuses[spec.id] = 'missing_required';
continue;
}
}
}
// Check upper bound
if (upperBounds[spec.id] < CREDIT_THRESHOLD) {
statuses[spec.id] = 'unreachable';
continue;
}
statuses[spec.id] = 'achievable';
}
return statuses;
}
/**
* Run the full optimization pipeline.
*/
export function optimize(
selectedCourseIds: string[],
ranking: string[],
openSetIds: string[],
mode: 'maximize-count' | 'priority-order',
): AllocationResult {
const fn = mode === 'maximize-count' ? maximizeCount : priorityOrder;
const { achieved, allocations } = fn(selectedCourseIds, ranking, openSetIds);
const statuses = determineStatuses(selectedCourseIds, openSetIds, achieved);
const upperBounds = computeUpperBounds(selectedCourseIds, openSetIds);
return { achieved, allocations, statuses, upperBounds };
}

166
app/src/state/appState.ts Normal file
View File

@@ -0,0 +1,166 @@
import { useReducer, useMemo, useEffect, useRef, useCallback, useState } from 'react';
import { SPECIALIZATIONS } from '../data/specializations';
import { ELECTIVE_SETS } from '../data/electiveSets';
import type { OptimizationMode, AllocationResult } from '../data/types';
import { optimize } from '../solver/optimizer';
import type { SetAnalysis } from '../solver/decisionTree';
import type { WorkerRequest, WorkerResponse } from '../workers/decisionTree.worker';
import DecisionTreeWorker from '../workers/decisionTree.worker?worker';
const STORAGE_KEY = 'emba-solver-state';
export interface AppState {
ranking: string[];
mode: OptimizationMode;
pinnedCourses: Record<string, string | null>; // setId -> courseId | null
}
type AppAction =
| { type: 'reorder'; ranking: string[] }
| { type: 'setMode'; mode: OptimizationMode }
| { type: 'pinCourse'; setId: string; courseId: string }
| { type: 'unpinCourse'; setId: string };
function reducer(state: AppState, action: AppAction): AppState {
switch (action.type) {
case 'reorder':
return { ...state, ranking: action.ranking };
case 'setMode':
return { ...state, mode: action.mode };
case 'pinCourse':
return { ...state, pinnedCourses: { ...state.pinnedCourses, [action.setId]: action.courseId } };
case 'unpinCourse': {
const next = { ...state.pinnedCourses };
delete next[action.setId];
return { ...state, pinnedCourses: next };
}
}
}
function defaultState(): AppState {
return {
ranking: SPECIALIZATIONS.map((s) => s.id),
mode: 'maximize-count',
pinnedCourses: {},
};
}
function loadState(): AppState {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return defaultState();
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed.ranking) || parsed.ranking.length !== 14) return defaultState();
if (!['maximize-count', 'priority-order'].includes(parsed.mode)) return defaultState();
return {
ranking: parsed.ranking,
mode: parsed.mode,
pinnedCourses: parsed.pinnedCourses ?? {},
};
} catch {
return defaultState();
}
}
export function useAppState() {
const [state, dispatch] = useReducer(reducer, null, loadState);
const [treeResults, setTreeResults] = useState<SetAnalysis[]>([]);
const [treeLoading, setTreeLoading] = useState(false);
const workerRef = useRef<Worker | null>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
// Persist to localStorage
useEffect(() => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
}, [state]);
// Derive selected courses and open sets
const selectedCourseIds = useMemo(
() => Object.values(state.pinnedCourses).filter((v): v is string => v != null),
[state.pinnedCourses],
);
const openSetIds = useMemo(
() => ELECTIVE_SETS.map((s) => s.id).filter((id) => !state.pinnedCourses[id]),
[state.pinnedCourses],
);
// Main-thread optimization (instant)
const optimizationResult: AllocationResult = useMemo(
() => optimize(selectedCourseIds, state.ranking, openSetIds, state.mode),
[selectedCourseIds, state.ranking, openSetIds, state.mode],
);
// Web Worker decision tree (debounced)
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
if (openSetIds.length === 0) {
setTreeResults([]);
setTreeLoading(false);
return;
}
setTreeLoading(true);
debounceRef.current = setTimeout(() => {
// Terminate previous worker if still running
if (workerRef.current) workerRef.current.terminate();
try {
const worker = new DecisionTreeWorker();
workerRef.current = worker;
const progressResults: SetAnalysis[] = [];
worker.onmessage = (e: MessageEvent<WorkerResponse>) => {
if (e.data.type === 'setComplete' && e.data.analysis) {
progressResults.push(e.data.analysis);
setTreeResults([...progressResults]);
} else if (e.data.type === 'allComplete' && e.data.analyses) {
setTreeResults(e.data.analyses);
setTreeLoading(false);
worker.terminate();
workerRef.current = null;
}
};
const request: WorkerRequest = {
pinnedCourseIds: selectedCourseIds,
openSetIds,
ranking: state.ranking,
mode: state.mode,
};
worker.postMessage(request);
} catch {
// Web Worker not available (e.g., test env) — skip
setTreeLoading(false);
}
}, 300);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
if (workerRef.current) {
workerRef.current.terminate();
workerRef.current = null;
}
};
}, [selectedCourseIds, openSetIds, state.ranking, state.mode]);
const reorder = useCallback((ranking: string[]) => dispatch({ type: 'reorder', ranking }), []);
const setMode = useCallback((mode: OptimizationMode) => dispatch({ type: 'setMode', mode }), []);
const pinCourse = useCallback((setId: string, courseId: string) => dispatch({ type: 'pinCourse', setId, courseId }), []);
const unpinCourse = useCallback((setId: string) => dispatch({ type: 'unpinCourse', setId }), []);
return {
state,
optimizationResult,
treeResults,
treeLoading,
openSetIds,
selectedCourseIds,
reorder,
setMode,
pinCourse,
unpinCourse,
};
}

View File

@@ -0,0 +1,36 @@
import { analyzeDecisionTree } from '../solver/decisionTree';
import type { OptimizationMode } from '../data/types';
import type { SetAnalysis } from '../solver/decisionTree';
export interface WorkerRequest {
pinnedCourseIds: string[];
openSetIds: string[];
ranking: string[];
mode: OptimizationMode;
}
export interface WorkerResponse {
type: 'setComplete' | 'allComplete';
analysis?: SetAnalysis;
analyses?: SetAnalysis[];
}
self.onmessage = (e: MessageEvent<WorkerRequest>) => {
const { pinnedCourseIds, openSetIds, ranking, mode } = e.data;
const analyses = analyzeDecisionTree(
pinnedCourseIds,
openSetIds,
ranking,
mode,
(analysis) => {
// Progressive update: send each set's results as they complete
const response: WorkerResponse = { type: 'setComplete', analysis };
self.postMessage(response);
},
);
// Final result with sorted analyses
const response: WorkerResponse = { type: 'allComplete', analyses };
self.postMessage(response);
};

28
app/tsconfig.app.json Normal file
View File

@@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
app/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

26
app/tsconfig.node.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

14
app/vite.config.ts Normal file
View File

@@ -0,0 +1,14 @@
/// <reference types="vitest" />
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
server: {
allowedHosts: ['soos'],
},
test: {
globals: true,
},
})

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-03-01

View File

@@ -0,0 +1,171 @@
## Context
Greenfield React web application. No existing code, backend, or infrastructure. The complete problem domain is documented in `SPECIALIZATION_EVALUATION.md`: 46 courses across 12 elective sets, 14 specializations, a course-specialization qualification matrix with special markers (■/S1/S2), required course gates, and a credit non-duplication constraint that makes this an optimization problem rather than simple counting.
The application runs entirely client-side. All data is embedded as static constants. The solver must be fast enough for instant feedback on every user interaction.
## Goals / Non-Goals
**Goals:**
- Exact optimal credit allocation (LP-based, not heuristic)
- Sub-second recalculation on any user interaction (rank change, pin, mode switch)
- Clear visibility into trade-offs: what each open-set decision enables or eliminates
- Works on modern browsers, no install or backend required
**Non-Goals:**
- Multi-user features, persistence, or accounts (local-only tool)
- Supporting arbitrary program structures (hardcoded to J27 EMBA curriculum)
- Mobile-optimized layout (desktop-first, functional on tablet)
- Printing or export
## Decisions
### 1. Project Tooling: Vite + React + TypeScript
**Choice:** Vite with React and TypeScript.
**Why:** Vite provides fast dev server and optimized builds. TypeScript catches data model errors at compile time — critical when the course-specialization matrix has ~100 entries that must be correct. No SSR needed since this is a static client-side tool.
**Alternatives considered:**
- Create React App: deprecated, slower builds
- Next.js: overkill for a single-page client-only tool
### 2. LP Solver: `javascript-lp-solver`
**Choice:** `javascript-lp-solver` — a pure JavaScript simplex implementation.
**Why:** The LP instances are tiny (~168 variables, ~26 constraints). This library is dependency-free, runs synchronously, and solves each instance in microseconds. No WASM compilation step or async loading. The API accepts a JSON model definition, which maps cleanly to our problem structure.
**Alternatives considered:**
- `glpk.js` (WASM GLPK port): more powerful, but WASM loading adds complexity for no benefit at this problem size
- Custom simplex: less maintenance burden on a library, but reinventing a solved problem
- No LP solver (combinatorial): credit splitting makes pure combinatorial approaches awkward; LP handles fractional allocation naturally
### 3. Optimization Architecture: Subset Enumeration + LP Feasibility
Both optimization modes share a common primitive: **given a fixed set of selected courses and a target set of specializations, is there a feasible credit allocation where every target spec reaches 9 credits?**
This is a small LP:
- Variables: `x[course][spec]` = credits allocated from course to spec
- Constraints: `Σ_spec x[c][s] ≤ 2.5` for each course, `Σ_course x[c][s] ≥ 9` for each target spec, `x[c][s] = 0` where course doesn't qualify, `x ≥ 0`
- Strategy S2: at most one S2-marked course has `x[c][strategy] > 0`
The two modes differ only in how they search for the best feasible target set:
**Maximize Count mode:**
1. Pre-filter: remove specs missing required courses → get candidate list
2. For n = 3 down to 0, enumerate `C(candidates, n)` subsets
3. For each subset, check LP feasibility (with S2 enumeration for Strategy)
4. Among feasible subsets of the max size, pick the one with best priority score: `Σ (15 - rank[spec])`
5. Return first feasible size found
**Priority Order mode:**
1. Pre-filter same as above
2. Start with `achieved = []`
3. For each spec in priority order: check if `{achieved spec}` is LP-feasible
4. If yes, add to achieved; if no, skip
5. At most 14 feasibility checks (one per spec)
**S2 enumeration:** When Strategy is in the target set, enumerate which S2 course (if any) counts. With typically 0-3 S2 courses selected, this adds at most 4 LP solves per subset check.
**Performance for fixed courses:** Max count mode worst case: `C(14,3) + C(14,2) + C(14,1) = 469` subsets × ~4 S2 options = ~1,876 LP solves. Each LP takes microseconds. Total: < 20ms.
### 4. Decision Tree: Per-Choice Impact Analysis
For open (unpinned) sets, the user needs to understand: "what does picking this course change?"
**Approach:** For each open set, for each course choice in that set:
1. Temporarily pin that course
2. Compute the **ceiling** — best achievable outcome assuming all *other* open sets are chosen optimally
3. Report: ceiling specialization count, which specs become achievable/unreachable vs. current state
**Ceiling computation** requires enumerating remaining open-set combinations. Cost: for each choice being evaluated, enumerate `4^(open-1)` remaining combos, each running the subset enumeration.
| Open sets | Combos per choice | × 4 choices × sets | Total optimizations |
|-----------|-------------------|---------------------|---------------------|
| 2 | 4 | 32 | 32 |
| 4 | 64 | 1,024 | 1,024 |
| 6 | 1,024 | 24,576 | 24,576 |
| 8 | 4,096 | 131,072 | 131,072 |
| 10 | 65,536 | 2,621,440 | too many |
At 6+ open sets, full enumeration becomes expensive (seconds). Solution:
**Tiered computation:**
- **Instant (main thread):** upper-bound reachability per spec using credit counting (ignores sharing). Runs on every interaction. Shows achievable/unreachable status.
- **Fast (main thread):** LP optimization on pinned courses only. Shows guaranteed achievements.
- **Background (Web Worker):** full decision tree enumeration. Runs after user interaction settles (debounced ~300ms). Progressively updates the UI as results arrive per open set.
**Early termination:** when computing ceilings, if we find a 3-spec feasible subset for a choice, we know the ceiling is 3 (the max) and can skip remaining combos for that choice.
**Impact ordering:** open sets are ranked by decision impact = variance in ceiling outcomes across their course choices. A set where all courses lead to the same outcome is "low impact." A set where one course enables 3 specs and another only 2 is "high impact." High-impact sets are shown first.
### 5. State Management: `useReducer` with Derived State
**Choice:** Single `useReducer` for user inputs, with derived computation results via `useMemo`.
```
State (persisted to localStorage):
├── specialization ranking: number[] (ordered spec IDs)
├── optimization mode: 'maximize-count' | 'priority-order'
└── pinned courses: Map<SetId, CourseId | null>
Derived (computed):
├── optimization result (from LP solver)
├── per-spec status + credit allocation
└── decision tree (from Web Worker)
```
**Why not Redux/Zustand:** Only three pieces of user state. A reducer handles the interactions (reorder, pin, toggle mode) cleanly without external dependencies.
### 6. Drag-and-Drop: `@dnd-kit`
**Choice:** `@dnd-kit/core` + `@dnd-kit/sortable` for specialization ranking.
**Why:** Modern, accessible (keyboard support), lightweight, well-maintained. The only drag-and-drop in the app is reordering a 14-item list.
**Alternatives considered:**
- `react-beautiful-dnd`: deprecated by Atlassian
- HTML5 drag-and-drop API: poor accessibility, inconsistent browser behavior
### 8. UI Verification: `agent-browser`
**Choice:** `agent-browser` (Vercel Labs) for interactive UI verification during development.
**Why:** The interesting behavior in this app is visual — credit allocations, status transitions, decision tree structure. Unit tests cover the optimizer, but verifying the UI wires everything together correctly requires interacting with the running app. `agent-browser` is a headless browser CLI designed for AI agents: it produces accessibility tree snapshots with semantic element references (`@e1`, `@e2`) that are easy to reason about programmatically, and supports click, fill, drag, and screenshot commands.
**How it's used:** During implementation, after building each UI component group, launch the Vite dev server and use `agent-browser` to:
1. Take accessibility tree snapshots to verify rendered structure (14 specializations listed, 12 elective sets grouped by term, status badges present)
2. Interact with the UI (drag to reorder, click to pin courses, toggle optimization mode)
3. Snapshot again to verify results updated correctly
4. Take screenshots for visual confirmation of layout and styling
This is **interactive verification, not a repeatable test suite**. It complements vitest unit tests (which cover data integrity and optimizer correctness) by catching UI wiring issues — wrong props, missing state connections, broken drag-and-drop — that unit tests cannot.
**Alternatives considered:**
- Playwright: more mature, better for repeatable e2e test suites, but overkill for this project's size. The app is small enough that interactive verification + unit tests cover the risk.
- Manual browser testing: works but slower and less systematic than scripted agent-browser commands.
### 9. Data Embedding: Typed Constants Module
The course-specialization matrix is embedded as a TypeScript module (`src/data/`) exporting typed constants:
- `COURSES`: array of `{ id, name, setId, specializations: { specId: '■' | 'S1' | 'S2' }[] }`
- `ELECTIVE_SETS`: array of `{ id, name, term, courseIds }`
- `SPECIALIZATIONS`: array of `{ id, name, abbreviation, requiredCourseId? }`
Derived lookups (courses-by-set, specs-by-course, etc.) are computed once at module load.
Data is transcribed from `SPECIALIZATION_EVALUATION.md` and verified by a unit test that checks row/column counts, required course mappings, and S1/S2 marker counts against known totals.
## Risks / Trade-offs
**[Decision tree too slow with many open sets]** → Web Worker + progressive rendering + early termination. If 10+ sets are open, fall back to upper-bound reachability only (no full enumeration). Acceptable because with 10 open sets the user hasn't made enough choices for precise decision analysis to matter.
**[Data transcription errors in embedded matrix]** → Unit test validates the embedded data against known aggregate counts (46 courses, 14 specs, specific marker totals per spec from the reachability table in SPECIALIZATION_EVALUATION.md). Any transcription error changes a count and fails the test.
**[LP solver library abandoned or buggy]** → `javascript-lp-solver` is simple enough that the relevant subset (feasibility checking) could be replaced with a custom implementation if needed. The LP instances are trivially small.
**[User confusion between optimization modes]** → Show both results side by side when they differ. When they agree, the mode toggle is less prominent. The interesting moment — when the modes disagree — is where the user learns something.

View File

@@ -0,0 +1,37 @@
## Why
EMBA students must select 12 elective courses (one per set) across three terms, earning 30 total credits. The program offers 14 specializations, each requiring 9+ credits from qualifying courses — but credits cannot be duplicated across specializations. Determining which specializations are achievable, and which course selections optimize toward preferred specializations, is a non-trivial credit allocation problem that students currently solve by intuition. A web application that solves this exactly would let students make informed decisions with full visibility into trade-offs.
## What Changes
- New React web application for EMBA specialization planning
- Embeds the full course-specialization matrix (46 courses, 14 specializations) as static data — no backend required
- Users rank specializations by priority (drag-to-reorder)
- Users pin course selections for any subset of the 12 elective sets
- Two optimization modes the user toggles between:
- **Maximize Count**: find the largest set of achievable specializations, using priority ranking to break ties among equal-count solutions
- **Priority Order**: guarantee the #1-ranked specialization first, then greedily add #2, #3, etc. (lexicographic optimization)
- Exact LP-based solver (not greedy heuristic) for credit allocation feasibility
- Decision tree for open (unpinned) sets: for each open set, show what each course choice enables or eliminates, ordered by decision impact
- Handles all program constraints: credit non-duplication, required course gates, Strategy S1/S2 cap, same-set mutual exclusions
## Capabilities
### New Capabilities
- `course-data`: Static data model embedding the 46 courses, 12 elective sets, 14 specializations, course-specialization qualification matrix (■/S1/S2 markers), and required course mappings
- `optimization-engine`: LP-based credit allocation solver supporting both optimization modes (maximize-count and priority-order), with feasibility checking across specialization subsets, Strategy S2 enumeration, and required course gate enforcement
- `specialization-ranking`: User interface for ordering specializations by priority via drag-and-drop, with mode toggle between maximize-count and priority-order optimization
- `course-selection`: Interface for pinning/unpinning course choices across the 12 elective sets, with immediate recalculation on change
- `results-dashboard`: Analysis output showing per-specialization status (achieved/achievable/unreachable/missing_required), credit allocation breakdown, and decision tree for open sets ordered by impact
### Modified Capabilities
_(none — greenfield project)_
## Impact
- New React application (single-page, client-side only)
- Dependencies: React, an in-browser LP solver (e.g., `javascript-lp-solver`), drag-and-drop library
- No backend, database, or API — all computation runs in the browser
- Data sourced from `SPECIALIZATION_EVALUATION.md` (already in repo)

View File

@@ -0,0 +1,60 @@
## ADDED Requirements
### Requirement: Elective set definitions
The system SHALL define 12 elective sets, each with an ID, display name, term (Spring/Summer/Fall), and an ordered list of course IDs. Sets 1 and 6 (Spring Set 1 and Summer Set 1) SHALL contain the same three courses.
#### Scenario: All 12 sets present
- **WHEN** the data module is loaded
- **THEN** exactly 12 elective sets are defined, covering Spring (sets 1-5), Summer (sets 6-8), and Fall (sets 9-12)
#### Scenario: Sets 1 and 6 share courses
- **WHEN** inspecting Spring Set 1 and Summer Set 1
- **THEN** both sets contain the same three course entries (Global Immersion Experience II, Collaboration Conflict and Negotiation, Conquering High Stakes Communication)
### Requirement: Course definitions
The system SHALL define 46 courses. Each course SHALL have an ID, display name, and the ID of the elective set it belongs to.
#### Scenario: Course count
- **WHEN** the data module is loaded
- **THEN** exactly 46 courses are defined
#### Scenario: Each course belongs to one set
- **WHEN** iterating all courses
- **THEN** every course references a valid elective set ID, and the set's course list includes that course
### Requirement: Specialization definitions
The system SHALL define 14 specializations. Each specialization SHALL have an ID, display name, and abbreviation. Specializations with a required course gate SHALL reference the required course ID.
#### Scenario: Specialization count
- **WHEN** the data module is loaded
- **THEN** exactly 14 specializations are defined
#### Scenario: Required course mappings
- **WHEN** inspecting specializations with required courses
- **THEN** exactly 4 specializations have required course gates: Sustainable Business and Innovation (Sustainability for Competitive Advantage), Entrepreneurship and Innovation (Foundations of Entrepreneurship), Entertainment Media and Technology (Entertainment and Media Industries), Brand Management (Brand Strategy)
### Requirement: Course-specialization qualification matrix
Each course SHALL declare which specializations it qualifies for, with a marker type of standard (■), S1, or S2. Courses with no qualifying specializations SHALL have an empty qualification list.
#### Scenario: Marker types
- **WHEN** inspecting course qualifications
- **THEN** every qualification entry uses one of three marker types: standard, S1, or S2
#### Scenario: Strategy markers
- **WHEN** counting Strategy-qualifying courses
- **THEN** exactly 10 courses have S1 markers and exactly 7 courses have S2 markers
#### Scenario: Qualification counts match reachability table
- **WHEN** counting qualifying courses per specialization (across distinct sets)
- **THEN** the counts match the "Across Sets" column in the reachability summary: Management 11, Strategy 9, Leadership and Change Management 9, Finance 9, Corporate Finance 8, Marketing 7, Banking 6, Brand Management 6, Financial Instruments and Markets 6, Management of Technology and Operations 6, Global Business 5, Entertainment Media and Technology 4, Entrepreneurship and Innovation 4, Sustainable Business and Innovation 4
### Requirement: Derived lookup indexes
The data module SHALL export pre-computed lookup maps: courses by set ID, qualifying specializations by course ID, and qualifying courses by specialization ID. These lookups SHALL be computed once at module load.
#### Scenario: Courses-by-set lookup
- **WHEN** querying courses for a given set ID
- **THEN** the returned list matches the set's defined course list in order
#### Scenario: Specs-by-course lookup
- **WHEN** querying specializations for a given course ID
- **THEN** the returned list contains all specializations the course qualifies for, with correct marker types

View File

@@ -0,0 +1,55 @@
## ADDED Requirements
### Requirement: Elective set display
The system SHALL display all 12 elective sets, grouped by term (Spring, Summer, Fall). Each set SHALL show its name and the list of available courses.
#### Scenario: Sets grouped by term
- **WHEN** viewing the course selection area
- **THEN** sets are displayed in three groups: Spring (5 sets), Summer (3 sets), Fall (4 sets), in set-number order within each group
### Requirement: Course pinning
The system SHALL allow the user to select exactly one course per elective set. Selecting a course pins it as the chosen course for that set. A set with no selected course is considered open.
#### Scenario: Pin a course
- **WHEN** the user selects "Mergers & Acquisitions" in Spring Elective Set 3
- **THEN** that course is pinned for the set, the set is no longer open, and the optimization recalculates
#### Scenario: Only one course per set
- **WHEN** a course is already pinned in a set and the user selects a different course
- **THEN** the previous selection is replaced with the new one
### Requirement: Course unpinning
The system SHALL allow the user to unpin a selected course, returning the set to open status.
#### Scenario: Unpin a course
- **WHEN** the user unpins the selected course in Spring Elective Set 3
- **THEN** the set returns to open status with no selected course, and the optimization recalculates
### Requirement: Immediate recalculation
The system SHALL trigger optimization recalculation immediately when a course is pinned or unpinned. The recalculation SHALL use the current specialization ranking and optimization mode.
#### Scenario: Pin triggers recalc
- **WHEN** the user pins a course in any set
- **THEN** the optimization result, specialization statuses, and decision tree update to reflect the new selection
### Requirement: Visual state indication
Each elective set SHALL visually indicate whether it is open (no course selected) or pinned (course selected). Pinned sets SHALL display the selected course name prominently.
#### Scenario: Open set appearance
- **WHEN** no course is selected in a set
- **THEN** the set is visually distinguished as open (e.g., dashed border, muted style) and shows all available courses as selectable options
#### Scenario: Pinned set appearance
- **WHEN** a course is pinned in a set
- **THEN** the set shows the selected course name prominently with a clear unpin control
### Requirement: Course selection persistence
The system SHALL persist pinned course selections to localStorage. On subsequent loads, the system SHALL restore saved selections.
#### Scenario: Restore saved pins
- **WHEN** the user reloads the page after pinning courses
- **THEN** previously pinned courses are restored and the optimization runs with the saved state
#### Scenario: Corrupted or missing localStorage
- **WHEN** localStorage data is missing or unparseable
- **THEN** all sets default to open (no selections)

View File

@@ -0,0 +1,130 @@
## ADDED Requirements
### Requirement: Credit allocation feasibility check
The system SHALL determine whether a target set of specializations can each reach 9 credits given a fixed set of selected courses. The check SHALL use linear programming to find a feasible allocation of credits from courses to specializations, subject to: each course allocates at most 2.5 credits total, only qualifying course-specialization pairs receive credits, and all allocations are non-negative.
#### Scenario: Feasible allocation exists
- **WHEN** checking feasibility for {Finance, Corporate Finance} with courses that include Valuation, Corporate Finance, The Financial Services Industry, and Behavioral Finance
- **THEN** the check returns feasible with an allocation where each target spec has at least 9.0 allocated credits and no course exceeds 2.5 total
#### Scenario: Infeasible allocation
- **WHEN** checking feasibility for {Finance, Corporate Finance, Banking} with only 3 qualifying courses across those specs
- **THEN** the check returns infeasible (7.5 total credits cannot satisfy 27 credits needed)
### Requirement: Required course gate enforcement
Before checking LP feasibility for a target set, the system SHALL verify that every specialization in the target set with a required course gate has its required course present in the selected courses. If any required course is missing, the specialization SHALL be excluded from candidates.
#### Scenario: Required course present
- **WHEN** Entertainment Media and Technology is in the target set and Entertainment and Media Industries is among selected courses
- **THEN** EMT passes the required course gate and proceeds to LP feasibility
#### Scenario: Required course absent
- **WHEN** Brand Management is in the target set but Brand Strategy is not among selected courses
- **THEN** Brand Management is excluded from candidates without running the LP
### Requirement: Strategy S2 constraint
When Strategy is in the target set, the system SHALL enforce that at most one S2-marked course contributes credits to Strategy. The system SHALL enumerate each possible S2 choice (including no S2 course) and check feasibility for each, returning feasible if any S2 choice produces a feasible allocation.
#### Scenario: Multiple S2 courses selected
- **WHEN** the selected courses include Digital Strategy (S2), Private Equity (S2), and Managing Change (S2), and Strategy is in the target set
- **THEN** the system checks 4 LP variants (one per S2 course contributing + none contributing) and returns the best feasible result
#### Scenario: No S2 courses selected
- **WHEN** no S2-marked courses are among the selected courses and Strategy is in the target set
- **THEN** only S1-marked courses contribute to Strategy and a single LP check is performed
### Requirement: Maximize Count optimization mode
In maximize-count mode, the system SHALL find the largest set of achievable specializations. It SHALL enumerate candidate subsets from size 3 down to 0, checking LP feasibility for each. Among all feasible subsets of the maximum size, it SHALL select the subset with the highest priority score, computed as the sum of (15 minus rank position) for each specialization in the subset.
#### Scenario: Three specializations achievable
- **WHEN** running maximize-count with courses that can feasibly support 3 specializations
- **THEN** the result contains exactly 3 achieved specializations and the system has verified no 3-subset with a higher priority score is also feasible
#### Scenario: Tie-breaking by priority
- **WHEN** two different 2-specialization subsets are both feasible
- **THEN** the system selects the subset whose priority score (sum of 15 minus rank for each spec) is higher
#### Scenario: No specializations achievable
- **WHEN** no pinned courses are selected (all sets open)
- **THEN** the result contains 0 achieved specializations from pinned courses alone
### Requirement: Priority Order optimization mode
In priority-order mode, the system SHALL process specializations in the user's ranked order. For each specialization, it SHALL check whether adding it to the current achieved set remains LP-feasible. If feasible, the specialization is added; if not, it is skipped. Processing continues through all 14 specializations.
#### Scenario: Top-ranked specialization guaranteed
- **WHEN** the #1-ranked specialization is achievable on its own
- **THEN** it is included in the result, even if including it reduces the total achievable count compared to maximize-count mode
#### Scenario: Lower-ranked spec skipped when infeasible
- **WHEN** the #3-ranked specialization cannot be added to the set {#1, #2} without violating credit constraints
- **THEN** #3 is skipped and the system proceeds to check #4
### Requirement: Per-specialization status determination
After optimization, the system SHALL assign each specialization one of four statuses: achieved (allocated credits >= 9 and required course present), achievable (not achieved but reachable through courses in open sets), missing_required (enough credits theoretically possible but required course not selected and not available in any open set), or unreachable (maximum potential credits from selected plus open sets < 9).
#### Scenario: Achieved status
- **WHEN** a specialization has 9+ allocated credits and its required course (if any) is selected
- **THEN** its status is achieved
#### Scenario: Achievable status
- **WHEN** a specialization is not achieved but qualifying courses exist in open sets that could bring credits to 9+
- **THEN** its status is achievable
#### Scenario: Missing required status
- **WHEN** a specialization's required course is not selected and the set containing it is already pinned to a different course
- **THEN** its status is missing_required
#### Scenario: Unreachable status
- **WHEN** maximum potential credits (selected qualifying courses × 2.5 + open sets with qualifying courses × 2.5) is less than 9
- **THEN** its status is unreachable
### Requirement: Credit allocation output
When the optimization produces achieved specializations, the system SHALL output the full credit allocation: for each selected course, how many credits are allocated to each qualifying specialization. The sum of allocations per course SHALL NOT exceed 2.5. The sum of allocations per achieved specialization SHALL be at least 9.0.
#### Scenario: Allocation detail
- **WHEN** viewing results after optimization
- **THEN** each achieved specialization shows which courses contribute credits and the amount from each, and the total per course across all specializations does not exceed 2.5
### Requirement: Upper-bound reachability computation
The system SHALL compute an upper-bound credit potential per specialization by summing 2.5 for each qualifying selected course plus 2.5 for each open set containing a qualifying course. This upper bound SHALL ignore credit sharing and be used only for reachability status determination, not for allocation.
#### Scenario: Upper bound with open sets
- **WHEN** a specialization has 2 qualifying pinned courses and 3 open sets with qualifying courses
- **THEN** the upper-bound potential is 12.5 (5 × 2.5)
### Requirement: Decision tree per-choice ceiling computation
For each open set, for each course choice in that set, the system SHALL compute the ceiling outcome: the best achievable result assuming that course is pinned and all other open sets are chosen optimally. The system SHALL enumerate remaining open-set combinations and run the full optimization for each, returning the best result found.
#### Scenario: Choice enables higher ceiling
- **WHEN** in Spring Set 2, choosing The Financial Services Industry
- **THEN** the ceiling computation evaluates all combinations of other open sets and reports the best achievable specialization count and set
#### Scenario: Early termination at max ceiling
- **WHEN** a course choice's ceiling reaches 3 specializations (the maximum)
- **THEN** the system stops enumerating remaining combinations for that choice
### Requirement: Decision tree impact ordering
Open sets in the decision tree SHALL be ordered by decision impact, defined as the variance in ceiling outcomes across course choices within the set. Sets where all course choices produce the same ceiling outcome SHALL be ranked lowest. Sets where course choices produce different ceiling outcomes SHALL be ranked highest.
#### Scenario: High-impact set
- **WHEN** an open set has 4 courses where one leads to ceiling 3 and the others lead to ceiling 2
- **THEN** this set is ranked higher than a set where all 4 courses lead to ceiling 3
#### Scenario: Equal-impact sets
- **WHEN** two open sets have identical variance in ceiling outcomes
- **THEN** they are ordered by term chronology (Spring before Summer before Fall)
### Requirement: Computation tiering
The system SHALL compute results in three tiers: instant upper-bound reachability on the main thread (runs on every interaction), fast LP optimization on pinned courses only on the main thread, and background decision tree enumeration in a Web Worker (debounced 300ms after user interaction). When 10 or more sets are open, the system SHALL skip background enumeration and show only upper-bound reachability for the decision tree.
#### Scenario: Instant feedback
- **WHEN** the user pins a course
- **THEN** upper-bound reachability and pinned-course optimization results update immediately (within one render cycle)
#### Scenario: Background tree computation
- **WHEN** the user has 6 open sets and changes a pin
- **THEN** the decision tree begins computing in a Web Worker after 300ms of inactivity and updates the UI progressively as each open set's analysis completes
#### Scenario: Fallback for many open sets
- **WHEN** 10 or more sets are open
- **THEN** the decision tree shows upper-bound reachability only, without ceiling computation

View File

@@ -0,0 +1,74 @@
## ADDED Requirements
### Requirement: Specialization status summary
The system SHALL display all 14 specializations with their current status: achieved, achievable, missing_required, or unreachable. Achieved specializations SHALL be visually prominent. The list SHALL follow the user's priority ranking order.
#### Scenario: Mixed statuses displayed
- **WHEN** the optimization produces 2 achieved, 5 achievable, 1 missing_required, and 6 unreachable specializations
- **THEN** all 14 are displayed in priority rank order, each with its status clearly indicated through distinct visual styling (color, icon, or badge)
#### Scenario: No courses pinned
- **WHEN** no courses are pinned (all sets open)
- **THEN** all specializations show as achievable or unreachable based on upper-bound reachability
### Requirement: Credit progress display
Each specialization SHALL display its current credit progress toward the 9-credit threshold. Achieved specializations SHALL show allocated credits. Achievable specializations SHALL show upper-bound potential credits. Unreachable specializations SHALL show maximum possible credits.
#### Scenario: Achieved specialization credits
- **WHEN** Finance has 9.5 credits allocated by the optimizer
- **THEN** Finance shows "9.5 / 9.0 credits" with a filled progress indicator
#### Scenario: Achievable specialization potential
- **WHEN** Marketing has 5.0 allocated credits from pinned courses and 3 open sets with qualifying courses
- **THEN** Marketing shows current allocation plus potential (e.g., "5.0 allocated, up to 12.5 potential")
### Requirement: Credit allocation breakdown
The system SHALL allow the user to view the detailed credit allocation for achieved specializations: which courses contribute credits and the amount from each course.
#### Scenario: Expand allocation detail
- **WHEN** the user expands an achieved specialization
- **THEN** a breakdown shows each contributing course name and the credit amount allocated from it (e.g., "Valuation: 2.5, Corporate Finance: 2.5, The Financial Services Industry: 2.5, Behavioral Finance: 1.5")
### Requirement: Decision tree for open sets
The system SHALL display a decision tree section showing open (unpinned) elective sets ordered by decision impact. For each open set, each course choice SHALL display its ceiling outcome: the best achievable specialization count and set assuming optimal choices in remaining open sets.
#### Scenario: High-impact set displayed first
- **WHEN** Spring Set 4 has high variance in ceiling outcomes across its courses (one choice enables 3 specs, another only 2) and Fall Set 2 has no variance (all choices lead to same ceiling)
- **THEN** Spring Set 4 appears before Fall Set 2 in the decision tree
#### Scenario: Course choice outcomes shown
- **WHEN** viewing an open set in the decision tree
- **THEN** each course shows: ceiling specialization count, the names of ceiling specializations, and any specs that become permanently unreachable if this course is chosen
### Requirement: Decision tree loading state
When the background Web Worker is computing ceiling outcomes, the system SHALL show a loading indicator on the decision tree. Results SHALL appear progressively as each open set's analysis completes.
#### Scenario: Progressive loading
- **WHEN** the Web Worker has completed analysis for 3 of 6 open sets
- **THEN** the 3 completed sets show full ceiling results and the remaining 3 show a loading indicator
#### Scenario: Instant fallback
- **WHEN** the Web Worker has not yet returned results
- **THEN** the decision tree shows upper-bound reachability per course choice (computed instantly on main thread) as a preliminary result
### Requirement: Mode comparison
When the two optimization modes (maximize-count and priority-order) produce different results for the current pinned courses, the system SHALL highlight the difference to help the user understand the trade-off.
#### Scenario: Modes agree
- **WHEN** both modes produce the same achieved specialization set
- **THEN** no comparison is shown; the result is displayed normally
#### Scenario: Modes disagree
- **WHEN** maximize-count achieves {Corp Finance, Strategy, Management} (3 specs) but priority-order achieves {Finance, Corp Finance} (2 specs, Finance is rank #1)
- **THEN** the system displays both outcomes with an explanation: maximize-count achieves more specializations, but priority-order guarantees the user's top-ranked specialization
### Requirement: Mutual exclusion warnings
When a course selection or open-set state creates a mutual exclusion (two specializations that cannot both be achieved), the system SHALL display a warning explaining the conflict.
#### Scenario: SBI vs E&I conflict
- **WHEN** Spring Set 4 is open
- **THEN** the system notes that choosing Sustainability for Competitive Advantage eliminates Entrepreneurship and Innovation (and vice versa) because both required courses are in the same set
#### Scenario: Conflict already resolved
- **WHEN** Spring Set 4 is pinned to Foundations of Entrepreneurship
- **THEN** the SBI specialization shows status missing_required with an explanation that its required course was in the same set as the pinned choice

View File

@@ -0,0 +1,59 @@
## ADDED Requirements
### Requirement: Specialization priority list
The system SHALL display all 14 specializations in a vertical ordered list. The position in the list represents the user's priority ranking, with position 1 being the highest priority.
#### Scenario: Initial state
- **WHEN** the application loads for the first time (no saved state)
- **THEN** all 14 specializations are displayed in their default order (Banking, Brand Management, Corporate Finance, Entertainment Media and Technology, Entrepreneurship and Innovation, Finance, Financial Instruments and Markets, Global Business, Leadership and Change Management, Management, Marketing, Management of Technology and Operations, Sustainable Business and Innovation, Strategy)
### Requirement: Drag-and-drop reordering
The system SHALL allow the user to reorder specializations by dragging items to a new position in the list. The drag interaction SHALL provide visual feedback showing the item being dragged and the target drop position.
#### Scenario: Drag specialization to new position
- **WHEN** the user drags the specialization at position 5 to position 1
- **THEN** the dragged specialization moves to position 1 and all items previously at positions 1-4 shift down by one
#### Scenario: Visual drag feedback
- **WHEN** the user begins dragging a specialization
- **THEN** the dragged item is visually distinguished (e.g., elevated, semi-transparent) and the list shows a drop indicator at the target position
### Requirement: Keyboard reordering
The system SHALL support keyboard-based reordering for accessibility. Users SHALL be able to select a specialization and move it up or down in the list using keyboard controls.
#### Scenario: Move item up with keyboard
- **WHEN** a specialization is focused and the user activates the move-up action
- **THEN** the specialization swaps with the item above it
#### Scenario: Move item at top boundary
- **WHEN** the specialization at position 1 is focused and the user activates move-up
- **THEN** nothing happens (item stays at position 1)
### Requirement: Optimization mode toggle
The system SHALL display a toggle control with two options: Maximize Count and Priority Order. Exactly one mode SHALL be active at any time. The toggle SHALL display a brief description of each mode.
#### Scenario: Switch to Priority Order
- **WHEN** the user selects Priority Order mode (from Maximize Count)
- **THEN** the toggle updates to show Priority Order as active and the optimization recalculates using the priority-order algorithm
#### Scenario: Default mode
- **WHEN** the application loads for the first time
- **THEN** Maximize Count mode is active
### Requirement: Recalculation on rank change
The system SHALL trigger a full optimization recalculation whenever the specialization ranking order changes. The recalculation SHALL use the current optimization mode and pinned courses.
#### Scenario: Rank change triggers recalc
- **WHEN** the user moves a specialization from position 3 to position 1
- **THEN** the optimization runs with the updated ranking and results reflect the new priority order
### Requirement: State persistence
The system SHALL persist the specialization ranking order and selected optimization mode to localStorage. On subsequent loads, the system SHALL restore the saved ranking and mode.
#### Scenario: Restore saved ranking
- **WHEN** the user reloads the page after reordering specializations
- **THEN** the specialization list appears in the previously saved order
#### Scenario: Corrupted or missing localStorage
- **WHEN** localStorage data is missing or unparseable
- **THEN** the system falls back to default ranking and Maximize Count mode

View File

@@ -0,0 +1,85 @@
## 1. Project Setup
- [x] 1.1 Scaffold Vite + React + TypeScript project with `npm create vite@latest`
- [x] 1.2 Install dependencies: `javascript-lp-solver`, `@dnd-kit/core`, `@dnd-kit/sortable`, `@dnd-kit/utilities`
- [x] 1.3 Install dev dependencies: `vitest` for unit testing, `agent-browser` for interactive UI verification
- [x] 1.4 Run `agent-browser install` to set up Chromium for headless browser automation
- [x] 1.5 Configure Vite and TypeScript settings, verify dev server runs
## 2. Data Layer
- [x] 2.1 Define TypeScript types: `ElectiveSet`, `Course`, `Specialization`, `MarkerType` ('standard' | 'S1' | 'S2'), `Qualification`
- [x] 2.2 Create `src/data/specializations.ts` with all 14 specializations (id, name, abbreviation, requiredCourseId)
- [x] 2.3 Create `src/data/electiveSets.ts` with all 12 elective sets (id, name, term, courseIds)
- [x] 2.4 Create `src/data/courses.ts` with all 46 courses (id, name, setId, qualifications array with specId and marker type), transcribed from SPECIALIZATION_EVALUATION.md
- [x] 2.5 Create `src/data/lookups.ts` with derived indexes: coursesBySet, specsByCourse, coursesBySpec (computed once at module load)
- [x] 2.6 Write data validation tests: verify 46 courses, 12 sets, 14 specs, 10 S1 markers, 7 S2 markers, 4 required course gates, and per-specialization "across sets" counts match the reachability table
## 3. Optimization Engine — Core
- [x] 3.1 Implement `checkFeasibility(selectedCourses, targetSpecs, s2Choice?)` — builds LP model and returns feasible/infeasible with allocation details
- [x] 3.2 Implement `preFilterCandidates(selectedCourses, openSets)` — removes specs missing required courses, returns candidate list
- [x] 3.3 Implement `enumerateS2Choices(selectedCourses)` — returns array of S2 course options (including null) for Strategy enumeration
- [x] 3.4 Implement `computeUpperBounds(selectedCourses, openSets)` — upper-bound credit potential per spec ignoring sharing
- [x] 3.5 Write unit tests for LP feasibility: feasible allocations, infeasible allocations, required course gating, S2 constraint enforcement
## 4. Optimization Engine — Modes
- [x] 4.1 Implement `maximizeCount(selectedCourses, ranking, openSets)` — enumerate subsets size 3→0, check feasibility, pick best priority score
- [x] 4.2 Implement `priorityOrder(selectedCourses, ranking, openSets)` — iterate specs in rank order, greedily add if feasible
- [x] 4.3 Implement `determineStatuses(selectedCourses, openSets, achieved)` — assign achieved/achievable/missing_required/unreachable per spec
- [x] 4.4 Write unit tests for both modes: verify correct achieved sets, priority tie-breaking, status determination, edge cases (no pins, all pins)
## 5. Decision Tree — Web Worker
- [x] 5.1 Create `src/workers/decisionTree.worker.ts` — receives selected courses + ranking + mode, computes ceiling per open set per course choice
- [x] 5.2 Implement ceiling computation with early termination (stop at 3-spec ceiling)
- [x] 5.3 Implement impact ordering: compute variance in ceiling outcomes per open set, sort descending (chronological tiebreak)
- [x] 5.4 Implement progressive messaging: worker posts results per open set as each completes
- [x] 5.5 Implement fallback: skip enumeration when 10+ sets are open, return upper-bound reachability only
- [x] 5.6 Write unit tests for ceiling computation and impact ordering logic (testable without worker wrapper)
## 6. State Management
- [x] 6.1 Define app state type: `{ ranking: string[], mode: 'maximize-count' | 'priority-order', pinnedCourses: Record<string, string | null> }`
- [x] 6.2 Implement `useReducer` with actions: `reorder`, `setMode`, `pinCourse`, `unpinCourse`
- [x] 6.3 Implement localStorage persistence: save on state change, restore on load, fallback to defaults on parse error
- [x] 6.4 Implement derived computation: `useMemo` for upper bounds + LP optimization on pinned courses (main thread)
- [x] 6.5 Implement Web Worker integration: debounced 300ms dispatch to decision tree worker, progressive state updates from worker messages
## 7. UI — Specialization Ranking
- [x] 7.1 Build `SpecializationRanking` component: vertical sortable list of 14 specializations using @dnd-kit/sortable
- [x] 7.2 Add drag handle, visual drag feedback (elevated/semi-transparent item, drop indicator)
- [x] 7.3 Add keyboard reordering support via @dnd-kit accessibility features
- [x] 7.4 Build `ModeToggle` component: two-option toggle with brief descriptions for Maximize Count and Priority Order
- [x] 7.5 Verify with agent-browser: snapshot ranking list shows 14 specializations, drag reorder works, mode toggle switches active state
## 8. UI — Course Selection
- [x] 8.1 Build `CourseSelection` component: 12 elective sets grouped by term (Spring/Summer/Fall sections)
- [x] 8.2 Build `ElectiveSet` component: shows set name, list of courses as selectable options, pin/unpin interaction
- [x] 8.3 Implement visual state indication: open sets (dashed border, all courses shown) vs pinned sets (selected course prominent, unpin control)
- [x] 8.4 Verify with agent-browser: snapshot shows 12 sets grouped by term, pin a course and confirm set updates to pinned state, unpin and confirm it reverts
## 9. UI — Results Dashboard
- [x] 9.1 Build `ResultsDashboard` component: displays 14 specializations in priority rank order with status badges (achieved/achievable/missing_required/unreachable)
- [x] 9.2 Build credit progress display: progress bar toward 9-credit threshold, show allocated vs potential credits
- [x] 9.3 Build expandable allocation breakdown for achieved specializations: list contributing courses and credit amounts
- [x] 9.4 Build `DecisionTree` component: open sets ordered by impact, each showing course choices with ceiling outcomes
- [x] 9.5 Add loading states for decision tree: show upper-bound fallback while Web Worker computes, progressive update as results arrive
- [x] 9.6 Build mode comparison display: when modes disagree, show both outcomes with explanation
- [x] 9.7 Build mutual exclusion warnings: highlight SBI/E&I conflict in Spring Set 4, and other required-course conflicts
- [x] 9.8 Verify with agent-browser: pin several courses, snapshot results dashboard to confirm status badges, credit progress, and allocation breakdown render correctly
## 10. App Layout & Integration
- [x] 10.1 Build `App` component layout: specialization ranking panel, course selection panel, results dashboard panel
- [x] 10.2 Wire state management to all components: ranking changes, pins, mode toggle all trigger recalculation
- [x] 10.3 Verify end-to-end flow: rank specs → pin courses → see results → explore decision tree
- [x] 10.4 Add basic CSS styling: clean layout, status colors, responsive enough for desktop/tablet
- [x] 10.5 Full agent-browser walkthrough: open app, reorder specializations, toggle mode, pin courses across multiple sets, verify results dashboard shows correct achieved/achievable statuses, explore decision tree, take final screenshot