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