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:
24
app/.gitignore
vendored
Normal file
24
app/.gitignore
vendored
Normal 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
73
app/README.md
Normal 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
23
app/eslint.config.js
Normal 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
13
app/index.html
Normal 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
6834
app/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
38
app/package.json
Normal file
38
app/package.json
Normal 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
1
app/public/vite.svg
Normal 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
0
app/src/App.css
Normal file
67
app/src/App.tsx
Normal file
67
app/src/App.tsx
Normal 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
1
app/src/assets/react.svg
Normal 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 |
111
app/src/components/CourseSelection.tsx
Normal file
111
app/src/components/CourseSelection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
55
app/src/components/ModeToggle.tsx
Normal file
55
app/src/components/ModeToggle.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
279
app/src/components/ResultsDashboard.tsx
Normal file
279
app/src/components/ResultsDashboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
152
app/src/components/SpecializationRanking.tsx
Normal file
152
app/src/components/SpecializationRanking.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
110
app/src/data/__tests__/data.test.ts
Normal file
110
app/src/data/__tests__/data.test.ts
Normal 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
247
app/src/data/courses.ts
Normal 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' }],
|
||||
},
|
||||
];
|
||||
52
app/src/data/electiveSets.ts
Normal file
52
app/src/data/electiveSets.ts
Normal 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
40
app/src/data/lookups.ts
Normal 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;
|
||||
}
|
||||
18
app/src/data/specializations.ts
Normal file
18
app/src/data/specializations.ts
Normal 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
40
app/src/data/types.ts
Normal 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
29
app/src/index.css
Normal 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
10
app/src/main.tsx
Normal 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>,
|
||||
)
|
||||
91
app/src/solver/__tests__/decisionTree.test.ts
Normal file
91
app/src/solver/__tests__/decisionTree.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
173
app/src/solver/__tests__/feasibility.test.ts
Normal file
173
app/src/solver/__tests__/feasibility.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
134
app/src/solver/__tests__/optimizer.test.ts
Normal file
134
app/src/solver/__tests__/optimizer.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
141
app/src/solver/decisionTree.ts
Normal file
141
app/src/solver/decisionTree.ts
Normal 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;
|
||||
}
|
||||
193
app/src/solver/feasibility.ts
Normal file
193
app/src/solver/feasibility.ts
Normal 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
199
app/src/solver/optimizer.ts
Normal 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
166
app/src/state/appState.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
36
app/src/workers/decisionTree.worker.ts
Normal file
36
app/src/workers/decisionTree.worker.ts
Normal 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
28
app/tsconfig.app.json
Normal 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
7
app/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
app/tsconfig.node.json
Normal file
26
app/tsconfig.node.json
Normal 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
14
app/vite.config.ts
Normal 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,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user