diff --git a/CHANGELOG.md b/CHANGELOG.md
index d7b492c..48b6cfd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,13 @@
# Changelog
+## v1.1.0 — 2026-03-13
+
+### Changes
+
+- **Cancelled course support** — "Managing Growing Companies" (Summer Elective Set 2) is marked as cancelled and rendered with strikethrough, greyed-out styling, and a "(Cancelled)" label; it is excluded from solver computations and decision tree enumeration
+- **Duplicate course prevention** — courses that appear in multiple elective sets (e.g., "Global Immersion Experience II" in Spring Set 1 and Summer Set 1, "The Financial Services Industry" in Spring Set 2 and Fall Set 4) are now linked; selecting one automatically disables and excludes its duplicate from selection and solver calculations, shown with an "(Already selected)" label
+- **Credit bar tick marks** — specialization progress bars now display light vertical tick marks at 2.5-credit intervals for visual scale reference, layered above bar fills with the 9.0 threshold marker remaining visually distinct
+
## v1.0.0 — 2026-02-28
Initial release of the EMBA Specialization Solver.
diff --git a/app/src/App.tsx b/app/src/App.tsx
index 3a6ec4c..f461c84 100644
--- a/app/src/App.tsx
+++ b/app/src/App.tsx
@@ -18,6 +18,8 @@ function App() {
treeLoading,
openSetIds,
selectedCourseIds,
+ disabledCourseIds,
+ excludedCourseIds,
reorder,
setMode,
pinCourse,
@@ -30,8 +32,8 @@ function App() {
// 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],
+ () => optimize(selectedCourseIds, state.ranking, openSetIds, altMode, excludedCourseIds),
+ [selectedCourseIds, state.ranking, openSetIds, altMode, excludedCourseIds],
);
const isMobile = breakpoint === 'mobile';
@@ -105,7 +107,7 @@ function App() {
EMBA Specialization Solver
- v{__APP_VERSION__}
+ v{__APP_VERSION__} ({__APP_VERSION_DATE__})
@@ -129,6 +131,7 @@ function App() {
pinnedCourses={state.pinnedCourses}
treeResults={treeResults}
treeLoading={treeLoading}
+ disabledCourseIds={disabledCourseIds}
onPin={pinCourse}
onUnpin={unpinCourse}
onClearAll={clearAll}
diff --git a/app/src/components/CourseSelection.tsx b/app/src/components/CourseSelection.tsx
index 0a79197..2fb6aca 100644
--- a/app/src/components/CourseSelection.tsx
+++ b/app/src/components/CourseSelection.tsx
@@ -16,6 +16,7 @@ interface CourseSelectionProps {
pinnedCourses: Record;
treeResults: SetAnalysis[];
treeLoading: boolean;
+ disabledCourseIds: Set;
onPin: (setId: string, courseId: string) => void;
onUnpin: (setId: string) => void;
onClearAll: () => void;
@@ -27,6 +28,7 @@ function ElectiveSet({
pinnedCourseId,
analysis,
loading,
+ disabledCourseIds,
onPin,
onUnpin,
}: {
@@ -35,6 +37,7 @@ function ElectiveSet({
pinnedCourseId: string | null | undefined;
analysis?: SetAnalysis;
loading: boolean;
+ disabledCourseIds: Set;
onPin: (courseId: string) => void;
onUnpin: () => void;
}) {
@@ -101,23 +104,47 @@ function ElectiveSet({
}}>
{courses.map((course) => {
+ const isCancelled = !!course.cancelled;
+ const isDisabled = disabledCourseIds.has(course.id);
+ const isUnavailable = isCancelled || isDisabled;
const ceiling = ceilingMap.get(course.id);
const reqFor = requiredForSpec[course.id];
const showSkeleton = loading && !analysis;
return (
onPin(course.id)}
+ onClick={isUnavailable ? undefined : () => onPin(course.id)}
+ disabled={isUnavailable}
style={{
display: 'flex', flexDirection: 'column', alignItems: 'stretch',
textAlign: 'left', padding: '6px 10px',
border: '1px solid #e5e7eb', borderRadius: '4px',
- background: '#fff', cursor: 'pointer', fontSize: '13px', color: '#333',
+ background: isUnavailable ? '#f5f5f5' : '#fff',
+ cursor: isUnavailable ? 'default' : 'pointer',
+ fontSize: '13px',
+ color: isUnavailable ? '#bbb' : '#333',
+ pointerEvents: isUnavailable ? 'none' : 'auto',
}}
>
- {course.name}
- {showSkeleton ? (
+
+ {course.name}
+ {isCancelled && (
+
+ (Cancelled)
+
+ )}
+ {!isCancelled && isDisabled && (
+
+ (Already selected)
+
+ )}
+
+ {!isUnavailable && showSkeleton ? (
- ) : ceiling ? (
+ ) : !isUnavailable && ceiling ? (
= 3 ? '#16a34a' : ceiling.ceilingCount >= 2 ? '#2563eb' : '#666',
@@ -143,7 +170,7 @@ function ElectiveSet({
) : null}
- {reqFor && (
+ {reqFor && !isUnavailable && (
Required for {reqFor.join(', ')}
@@ -159,7 +186,7 @@ function ElectiveSet({
const skeletonStyle = `@keyframes skeleton-pulse { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }`;
-export function CourseSelection({ pinnedCourses, treeResults, treeLoading, onPin, onUnpin, onClearAll }: CourseSelectionProps) {
+export function CourseSelection({ pinnedCourses, treeResults, treeLoading, disabledCourseIds, onPin, onUnpin, onClearAll }: CourseSelectionProps) {
const terms: Term[] = ['Spring', 'Summer', 'Fall'];
const hasPinned = Object.keys(pinnedCourses).length > 0;
@@ -200,6 +227,7 @@ export function CourseSelection({ pinnedCourses, treeResults, treeLoading, onPin
pinnedCourseId={pinnedCourses[set.id]}
analysis={treeBySet.get(set.id)}
loading={treeLoading}
+ disabledCourseIds={disabledCourseIds}
onPin={(courseId) => onPin(set.id, courseId)}
onUnpin={() => onUnpin(set.id)}
/>
diff --git a/app/src/components/SpecializationRanking.tsx b/app/src/components/SpecializationRanking.tsx
index 3a41876..7d9ad7d 100644
--- a/app/src/components/SpecializationRanking.tsx
+++ b/app/src/components/SpecializationRanking.tsx
@@ -33,6 +33,12 @@ function CreditBar({ allocated, potential, threshold }: { allocated: number; pot
const allocPct = Math.min((allocated / maxWidth) * 100, 100);
const potentialPct = Math.min((potential / maxWidth) * 100, 100);
+ // Generate tick marks at 2.5 credit intervals
+ const ticks: number[] = [];
+ for (let t = 2.5; t < maxWidth; t += 2.5) {
+ ticks.push(t);
+ }
+
return (
{potential > allocated && (
@@ -51,11 +57,22 @@ function CreditBar({ allocated, potential, threshold }: { allocated: number; pot
borderRadius: '3px', transition: 'width 300ms ease-out',
}}
/>
+ {/* Tick marks at 2.5 credit intervals — rendered above bar fills */}
+ {ticks.map((t) => (
+
+ ))}
diff --git a/app/src/data/courses.ts b/app/src/data/courses.ts
index 73083cd..1a25130 100644
--- a/app/src/data/courses.ts
+++ b/app/src/data/courses.ts
@@ -110,6 +110,7 @@ export const COURSES: Course[] = [
// === Summer Elective Set 2 ===
{
id: 'sum2-managing-growing', name: 'Managing Growing Companies', setId: 'sum2',
+ cancelled: true,
qualifications: [
{ specId: 'ENT', marker: 'standard' }, { specId: 'LCM', marker: 'standard' },
{ specId: 'MGT', marker: 'standard' }, { specId: 'STR', marker: 'S1' },
diff --git a/app/src/data/lookups.ts b/app/src/data/lookups.ts
index f3c206e..444c15b 100644
--- a/app/src/data/lookups.ts
+++ b/app/src/data/lookups.ts
@@ -38,3 +38,15 @@ export const setIdByCourse: Record = {};
for (const course of COURSES) {
setIdByCourse[course.id] = course.setId;
}
+
+// Cancelled course IDs
+export const cancelledCourseIds = new Set(
+ COURSES.filter((c) => c.cancelled).map((c) => c.id),
+);
+
+// Course IDs indexed by course name (for detecting duplicates across sets)
+export const courseIdsByName: Record = {};
+for (const course of COURSES) {
+ if (course.cancelled) continue;
+ (courseIdsByName[course.name] ??= []).push(course.id);
+}
diff --git a/app/src/data/types.ts b/app/src/data/types.ts
index a8c61c2..674e9ec 100644
--- a/app/src/data/types.ts
+++ b/app/src/data/types.ts
@@ -19,6 +19,7 @@ export interface Course {
name: string;
setId: string;
qualifications: Qualification[];
+ cancelled?: boolean;
}
export interface Specialization {
diff --git a/app/src/solver/decisionTree.ts b/app/src/solver/decisionTree.ts
index d14a58d..399c69c 100644
--- a/app/src/solver/decisionTree.ts
+++ b/app/src/solver/decisionTree.ts
@@ -30,13 +30,14 @@ function computeCeiling(
otherOpenSetIds: string[],
ranking: string[],
mode: OptimizationMode,
+ excludedCourseIds?: Set,
): { 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, []);
+ const result = fn(selected, ranking, [], excludedCourseIds);
return { count: result.achieved.length, specs: result.achieved };
}
@@ -50,7 +51,7 @@ function computeCeiling(
if (setIndex >= otherOpenSetIds.length) {
const selected = [...basePinnedCourses, chosenCourseId, ...accumulated];
- const result = fn(selected, ranking, []);
+ const result = fn(selected, ranking, [], excludedCourseIds);
if (result.achieved.length > bestCount) {
bestCount = result.achieved.length;
bestSpecs = result.achieved;
@@ -61,6 +62,7 @@ function computeCeiling(
const setId = otherOpenSetIds[setIndex];
const courses = coursesBySet[setId];
for (const course of courses) {
+ if (excludedCourseIds?.has(course.id)) continue;
enumerate(setIndex + 1, [...accumulated, course.id]);
if (bestCount >= 3) return;
}
@@ -91,6 +93,7 @@ export function analyzeDecisionTree(
ranking: string[],
mode: OptimizationMode,
onSetComplete?: (analysis: SetAnalysis) => void,
+ excludedCourseIds?: Set,
): SetAnalysis[] {
if (openSetIds.length > MAX_OPEN_SETS_FOR_ENUMERATION) {
// Fallback: return empty analyses (caller uses upper bounds instead)
@@ -107,21 +110,24 @@ export function analyzeDecisionTree(
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 choices: ChoiceOutcome[] = courses
+ .filter((course) => !excludedCourseIds?.has(course.id))
+ .map((course) => {
+ const ceiling = computeCeiling(
+ pinnedCourseIds,
+ course.id,
+ otherOpenSets,
+ ranking,
+ mode,
+ excludedCourseIds,
+ );
+ 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 };
diff --git a/app/src/solver/feasibility.ts b/app/src/solver/feasibility.ts
index a1487f5..1de9dcf 100644
--- a/app/src/solver/feasibility.ts
+++ b/app/src/solver/feasibility.ts
@@ -107,6 +107,7 @@ export function checkFeasibility(
export function preFilterCandidates(
selectedCourseIds: string[],
openSetIds: string[],
+ excludedCourseIds?: Set,
): string[] {
const selectedSet = new Set(selectedCourseIds);
const openSetSet = new Set(openSetIds);
@@ -128,6 +129,7 @@ export function preFilterCandidates(
let potential = 0;
const countedSets = new Set();
for (const e of entries) {
+ if (excludedCourseIds?.has(e.courseId)) continue;
const setId = setIdByCourse[e.courseId];
if (selectedSet.has(e.courseId)) {
if (!countedSets.has(setId)) {
@@ -165,6 +167,7 @@ export function enumerateS2Choices(selectedCourseIds: string[]): (string | null)
export function computeUpperBounds(
selectedCourseIds: string[],
openSetIds: string[],
+ excludedCourseIds?: Set,
): Record {
const selectedSet = new Set(selectedCourseIds);
const openSetSet = new Set(openSetIds);
@@ -175,6 +178,7 @@ export function computeUpperBounds(
let potential = 0;
const countedSets = new Set();
for (const e of entries) {
+ if (excludedCourseIds?.has(e.courseId)) continue;
const setId = setIdByCourse[e.courseId];
if (selectedSet.has(e.courseId)) {
if (!countedSets.has(setId)) {
diff --git a/app/src/solver/optimizer.ts b/app/src/solver/optimizer.ts
index daf4dc8..b5a1784 100644
--- a/app/src/solver/optimizer.ts
+++ b/app/src/solver/optimizer.ts
@@ -56,8 +56,9 @@ export function maximizeCount(
selectedCourseIds: string[],
ranking: string[],
openSetIds: string[],
+ excludedCourseIds?: Set,
): { achieved: string[]; allocations: Record> } {
- const candidates = preFilterCandidates(selectedCourseIds, openSetIds);
+ const candidates = preFilterCandidates(selectedCourseIds, openSetIds, excludedCourseIds);
// Only check specs that can be achieved from selected courses alone (not open sets)
// Filter to candidates that have qualifying selected courses
@@ -108,8 +109,9 @@ export function priorityOrder(
selectedCourseIds: string[],
ranking: string[],
openSetIds: string[],
+ excludedCourseIds?: Set,
): { achieved: string[]; allocations: Record> } {
- const candidates = new Set(preFilterCandidates(selectedCourseIds, openSetIds));
+ const candidates = new Set(preFilterCandidates(selectedCourseIds, openSetIds, excludedCourseIds));
// Only consider specs that have qualifying selected courses
const withSelectedCourses = new Set(
@@ -145,11 +147,12 @@ export function determineStatuses(
selectedCourseIds: string[],
openSetIds: string[],
achieved: string[],
+ excludedCourseIds?: Set,
): Record {
const achievedSet = new Set(achieved);
const selectedSet = new Set(selectedCourseIds);
const openSetSet = new Set(openSetIds);
- const upperBounds = computeUpperBounds(selectedCourseIds, openSetIds);
+ const upperBounds = computeUpperBounds(selectedCourseIds, openSetIds, excludedCourseIds);
const statuses: Record = {};
for (const spec of SPECIALIZATIONS) {
@@ -189,11 +192,12 @@ export function optimize(
ranking: string[],
openSetIds: string[],
mode: 'maximize-count' | 'priority-order',
+ excludedCourseIds?: Set,
): 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);
+ const { achieved, allocations } = fn(selectedCourseIds, ranking, openSetIds, excludedCourseIds);
+ const statuses = determineStatuses(selectedCourseIds, openSetIds, achieved, excludedCourseIds);
+ const upperBounds = computeUpperBounds(selectedCourseIds, openSetIds, excludedCourseIds);
return { achieved, allocations, statuses, upperBounds };
}
diff --git a/app/src/state/appState.ts b/app/src/state/appState.ts
index 8104d1b..880607a 100644
--- a/app/src/state/appState.ts
+++ b/app/src/state/appState.ts
@@ -5,6 +5,7 @@ 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 { cancelledCourseIds, courseIdsByName, courseById } from '../data/lookups';
import DecisionTreeWorker from '../workers/decisionTree.worker?worker';
const STORAGE_KEY = 'emba-solver-state';
@@ -88,10 +89,32 @@ export function useAppState() {
[state.pinnedCourses],
);
+ // Derive disabled course IDs: cancelled courses + duplicates of pinned courses
+ const { disabledCourseIds, excludedCourseIds } = useMemo(() => {
+ const disabled = new Set(cancelledCourseIds);
+ const excluded = new Set(cancelledCourseIds);
+
+ for (const courseId of selectedCourseIds) {
+ const course = courseById[courseId];
+ if (!course) continue;
+ const duplicates = courseIdsByName[course.name];
+ if (duplicates && duplicates.length > 1) {
+ for (const dupId of duplicates) {
+ if (dupId !== courseId) {
+ disabled.add(dupId);
+ excluded.add(dupId);
+ }
+ }
+ }
+ }
+
+ return { disabledCourseIds: disabled, excludedCourseIds: excluded };
+ }, [selectedCourseIds]);
+
// Main-thread optimization (instant)
const optimizationResult: AllocationResult = useMemo(
- () => optimize(selectedCourseIds, state.ranking, openSetIds, state.mode),
- [selectedCourseIds, state.ranking, openSetIds, state.mode],
+ () => optimize(selectedCourseIds, state.ranking, openSetIds, state.mode, excludedCourseIds),
+ [selectedCourseIds, state.ranking, openSetIds, state.mode, excludedCourseIds],
);
// Web Worker decision tree (debounced)
@@ -132,6 +155,7 @@ export function useAppState() {
openSetIds,
ranking: state.ranking,
mode: state.mode,
+ excludedCourseIds: [...excludedCourseIds],
};
worker.postMessage(request);
} catch {
@@ -147,7 +171,7 @@ export function useAppState() {
workerRef.current = null;
}
};
- }, [selectedCourseIds, openSetIds, state.ranking, state.mode]);
+ }, [selectedCourseIds, openSetIds, state.ranking, state.mode, excludedCourseIds]);
const reorder = useCallback((ranking: string[]) => dispatch({ type: 'reorder', ranking }), []);
const setMode = useCallback((mode: OptimizationMode) => dispatch({ type: 'setMode', mode }), []);
@@ -162,6 +186,8 @@ export function useAppState() {
treeLoading,
openSetIds,
selectedCourseIds,
+ disabledCourseIds,
+ excludedCourseIds,
reorder,
setMode,
pinCourse,
diff --git a/app/src/vite-env.d.ts b/app/src/vite-env.d.ts
index dbb4c62..84fe87c 100644
--- a/app/src/vite-env.d.ts
+++ b/app/src/vite-env.d.ts
@@ -1,3 +1,4 @@
///
declare const __APP_VERSION__: string;
+declare const __APP_VERSION_DATE__: string;
diff --git a/app/src/workers/decisionTree.worker.ts b/app/src/workers/decisionTree.worker.ts
index e7a1e41..2efa495 100644
--- a/app/src/workers/decisionTree.worker.ts
+++ b/app/src/workers/decisionTree.worker.ts
@@ -7,6 +7,7 @@ export interface WorkerRequest {
openSetIds: string[];
ranking: string[];
mode: OptimizationMode;
+ excludedCourseIds?: string[];
}
export interface WorkerResponse {
@@ -16,7 +17,10 @@ export interface WorkerResponse {
}
self.onmessage = (e: MessageEvent) => {
- const { pinnedCourseIds, openSetIds, ranking, mode } = e.data;
+ const { pinnedCourseIds, openSetIds, ranking, mode, excludedCourseIds } = e.data;
+ const excludedSet = excludedCourseIds && excludedCourseIds.length > 0
+ ? new Set(excludedCourseIds)
+ : undefined;
const analyses = analyzeDecisionTree(
pinnedCourseIds,
@@ -28,6 +32,7 @@ self.onmessage = (e: MessageEvent) => {
const response: WorkerResponse = { type: 'setComplete', analysis };
self.postMessage(response);
},
+ excludedSet,
);
// Final result with sorted analyses
diff --git a/app/vite.config.ts b/app/vite.config.ts
index 55fdf45..ebace54 100644
--- a/app/vite.config.ts
+++ b/app/vite.config.ts
@@ -6,7 +6,8 @@ import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
define: {
- __APP_VERSION__: JSON.stringify('1.0.0'),
+ __APP_VERSION__: JSON.stringify('1.1.0'),
+ __APP_VERSION_DATE__: JSON.stringify('2026-03-13'),
},
server: {
allowedHosts: ['soos'],