3 Commits

Author SHA1 Message Date
99a39a2581 v1.1.1: Replace cancelled Managing Growing Companies with Innovation and Design
Replace cancelled course in Summer Elective Set 2 with new course
"Innovation and Design" qualifying for Brand Management, Entrepreneurship
and Innovation, Marketing, and Strategy (S2).
2026-03-27 11:26:53 -04:00
8b887f7750 v1.1.0: Add cancelled course, duplicate prevention, and credit bar ticks
- Mark "Managing Growing Companies" as cancelled with visual indicator and solver exclusion
- Prevent selecting duplicate courses across elective sets (e.g., same course in Spring and Summer)
- Add 2.5-credit interval tick marks to specialization progress bars
- Bump version to 1.1.0 with date display in UI header
2026-03-13 16:11:56 -04:00
5c598d1fc6 Add version number display to UI header
Inject app version (v1.0.0) at build time via Vite define config
and display it below the title in muted text.
2026-03-13 15:45:58 -04:00
22 changed files with 270 additions and 48 deletions

View File

@@ -1,5 +1,19 @@
# Changelog
## v1.1.1 — 2026-03-27
### Changes
- **Course replacement** — replaced cancelled "Managing Growing Companies" with new course "Innovation and Design" in Summer Elective Set 2; qualifies for Brand Management, Entrepreneurship and Innovation, Marketing, and Strategy (S2)
## 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.

View File

@@ -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';
@@ -102,9 +104,10 @@ function App() {
/>
</>
)}
<h1 style={{ fontSize: '20px', marginBottom: '12px', color: '#111' }}>
<h1 style={{ fontSize: '20px', marginBottom: '2px', color: '#111' }}>
EMBA Specialization Solver
</h1>
<div style={{ fontSize: '11px', color: '#999', marginBottom: '12px' }}>v{__APP_VERSION__} ({__APP_VERSION_DATE__})</div>
<ModeToggle mode={state.mode} onSetMode={setMode} />
@@ -128,6 +131,7 @@ function App() {
pinnedCourses={state.pinnedCourses}
treeResults={treeResults}
treeLoading={treeLoading}
disabledCourseIds={disabledCourseIds}
onPin={pinCourse}
onUnpin={unpinCourse}
onClearAll={clearAll}

View File

@@ -16,6 +16,7 @@ interface CourseSelectionProps {
pinnedCourses: Record<string, string | null>;
treeResults: SetAnalysis[];
treeLoading: boolean;
disabledCourseIds: Set<string>;
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<string>;
onPin: (courseId: string) => void;
onUnpin: () => void;
}) {
@@ -101,23 +104,47 @@ function ElectiveSet({
}}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
{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 (
<button
key={course.id}
onClick={() => 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',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '8px' }}>
<span style={{ flex: 1 }}>{course.name}</span>
{showSkeleton ? (
<span style={{
flex: 1,
textDecoration: isCancelled ? 'line-through' : 'none',
fontStyle: isCancelled ? 'italic' : 'normal',
}}>
{course.name}
{isCancelled && (
<span style={{ fontSize: '11px', color: '#999', marginLeft: '6px', fontStyle: 'normal', textDecoration: 'none' }}>
(Cancelled)
</span>
)}
{!isCancelled && isDisabled && (
<span style={{ fontSize: '11px', color: '#999', marginLeft: '6px' }}>
(Already selected)
</span>
)}
</span>
{!isUnavailable && showSkeleton ? (
<span
style={{
display: 'inline-block',
@@ -129,7 +156,7 @@ function ElectiveSet({
animation: 'skeleton-pulse 1.5s ease-in-out infinite',
}}
/>
) : ceiling ? (
) : !isUnavailable && ceiling ? (
<span style={{
fontSize: '11px', whiteSpace: 'nowrap', fontWeight: 600,
color: ceiling.ceilingCount >= 3 ? '#16a34a' : ceiling.ceilingCount >= 2 ? '#2563eb' : '#666',
@@ -143,7 +170,7 @@ function ElectiveSet({
</span>
) : null}
</div>
{reqFor && (
{reqFor && !isUnavailable && (
<span style={{ fontSize: '11px', color: '#92400e', marginTop: '2px' }}>
Required for {reqFor.join(', ')}
</span>
@@ -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)}
/>

View File

@@ -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 (
<div style={{ position: 'relative', height: '6px', background: '#e5e7eb', borderRadius: '3px', marginTop: '4px' }}>
{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) => (
<div
key={t}
style={{
position: 'absolute', left: `${(t / maxWidth) * 100}%`, top: 0,
width: '1px', height: '6px', background: 'rgba(0,0,0,0.2)',
zIndex: 1, transition: 'left 300ms ease-out',
}}
/>
))}
<div
style={{
position: 'absolute', left: `${(threshold / maxWidth) * 100}%`, top: '-2px',
width: '2px', height: '10px', background: '#666',
transition: 'left 300ms ease-out',
zIndex: 2, transition: 'left 300ms ease-out',
}}
/>
</div>

View File

@@ -50,7 +50,7 @@ describe('Data integrity', () => {
});
});
it('has exactly 10 S1 markers and 7 S2 markers for Strategy', () => {
it('has exactly 9 S1 markers and 8 S2 markers for Strategy', () => {
let s1Count = 0;
let s2Count = 0;
for (const course of COURSES) {
@@ -59,8 +59,8 @@ describe('Data integrity', () => {
if (q.specId === 'STR' && q.marker === 'S2') s2Count++;
}
}
expect(s1Count).toBe(10);
expect(s2Count).toBe(7);
expect(s1Count).toBe(9);
expect(s2Count).toBe(8);
});
it('all qualification markers are valid types', () => {

View File

@@ -109,10 +109,10 @@ export const COURSES: Course[] = [
// === Summer Elective Set 2 ===
{
id: 'sum2-managing-growing', name: 'Managing Growing Companies', setId: 'sum2',
id: 'sum2-innovation-design', name: 'Innovation and Design', setId: 'sum2',
qualifications: [
{ specId: 'ENT', marker: 'standard' }, { specId: 'LCM', marker: 'standard' },
{ specId: 'MGT', marker: 'standard' }, { specId: 'STR', marker: 'S1' },
{ specId: 'BRM', marker: 'standard' }, { specId: 'ENT', marker: 'standard' },
{ specId: 'MKT', marker: 'standard' }, { specId: 'STR', marker: 'S2' },
],
},
{

View File

@@ -27,7 +27,7 @@ export const ELECTIVE_SETS: ElectiveSet[] = [
},
{
id: 'sum2', name: 'Summer Elective Set 2', term: 'Summer',
courseIds: ['sum2-managing-growing', 'sum2-social-media', 'sum2-leading-ai', 'sum2-business-drivers'],
courseIds: ['sum2-innovation-design', 'sum2-social-media', 'sum2-leading-ai', 'sum2-business-drivers'],
},
{
id: 'sum3', name: 'Summer Elective Set 3', term: 'Summer',

View File

@@ -38,3 +38,15 @@ export const setIdByCourse: Record<string, string> = {};
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<string, string[]> = {};
for (const course of COURSES) {
if (course.cancelled) continue;
(courseIdsByName[course.name] ??= []).push(course.id);
}

View File

@@ -19,6 +19,7 @@ export interface Course {
name: string;
setId: string;
qualifications: Qualification[];
cancelled?: boolean;
}
export interface Specialization {

View File

@@ -23,7 +23,7 @@ describe('analyzeDecisionTree', () => {
'spr4-fintech',
'spr5-corporate-finance',
'sum1-collaboration',
'sum2-managing-growing',
'sum2-innovation-design',
'sum3-valuation',
'fall1-private-equity',
'fall2-behavioral-finance',
@@ -50,7 +50,7 @@ describe('analyzeDecisionTree', () => {
'spr4-fintech',
'spr5-corporate-finance',
'sum1-collaboration',
'sum2-managing-growing',
'sum2-innovation-design',
'sum3-valuation',
'fall1-private-equity',
'fall2-behavioral-finance',
@@ -72,7 +72,7 @@ describe('analyzeDecisionTree', () => {
'spr4-fintech',
'spr5-corporate-finance',
'sum1-collaboration',
'sum2-managing-growing',
'sum2-innovation-design',
'sum3-valuation',
'fall1-private-equity',
'fall2-behavioral-finance',

View File

@@ -30,13 +30,14 @@ function computeCeiling(
otherOpenSetIds: string[],
ranking: string[],
mode: OptimizationMode,
excludedCourseIds?: Set<string>,
): { 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<string>,
): 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 };

View File

@@ -107,6 +107,7 @@ export function checkFeasibility(
export function preFilterCandidates(
selectedCourseIds: string[],
openSetIds: string[],
excludedCourseIds?: Set<string>,
): 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<string>();
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<string>,
): Record<string, number> {
const selectedSet = new Set(selectedCourseIds);
const openSetSet = new Set(openSetIds);
@@ -175,6 +178,7 @@ export function computeUpperBounds(
let potential = 0;
const countedSets = new Set<string>();
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)) {

View File

@@ -56,8 +56,9 @@ export function maximizeCount(
selectedCourseIds: string[],
ranking: string[],
openSetIds: string[],
excludedCourseIds?: Set<string>,
): { achieved: string[]; allocations: Record<string, Record<string, number>> } {
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<string>,
): { achieved: string[]; allocations: Record<string, Record<string, number>> } {
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<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 upperBounds = computeUpperBounds(selectedCourseIds, openSetIds, excludedCourseIds);
const statuses: Record<string, SpecStatus> = {};
for (const spec of SPECIALIZATIONS) {
@@ -189,11 +192,12 @@ export function optimize(
ranking: string[],
openSetIds: string[],
mode: 'maximize-count' | 'priority-order',
excludedCourseIds?: Set<string>,
): 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 };
}

View File

@@ -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,

4
app/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,4 @@
/// <reference types="vite/client" />
declare const __APP_VERSION__: string;
declare const __APP_VERSION_DATE__: string;

View File

@@ -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<WorkerRequest>) => {
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<WorkerRequest>) => {
const response: WorkerResponse = { type: 'setComplete', analysis };
self.postMessage(response);
},
excludedSet,
);
// Final result with sorted analyses

View File

@@ -5,6 +5,10 @@ import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
define: {
__APP_VERSION__: JSON.stringify('1.1.1'),
__APP_VERSION_DATE__: JSON.stringify('2026-03-27'),
},
server: {
allowedHosts: ['soos'],
},

View File

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

View File

@@ -0,0 +1,26 @@
## Context
Summer Elective Set 2 currently contains a cancelled course "Managing Growing Companies" (`sum2-managing-growing`) marked with `cancelled: true`. It qualifies for ENT, LCM, MGT, and STR (S1). A replacement course "Innovation and Design" has been announced, qualifying for BRM, ENT, MKT, and STR (S2).
## Goals / Non-Goals
**Goals:**
- Replace the cancelled course data entry with the new course
- Update all test assertions to match the new data
**Non-Goals:**
- No UI changes needed — the `cancelled` flag removal and new course entry are handled by existing rendering logic
- No solver logic changes — the solver already excludes cancelled courses and will automatically include the new one
## Decisions
### Direct replacement over keeping both entries
Replace the cancelled course entry rather than adding a new entry alongside it. The cancelled course has no historical value in the UI since the app is a forward-looking course selection tool. This keeps the course count at 46 and avoids unnecessary complexity.
### Specialization marker choices
All qualifications use `standard` marker except Strategy which uses `S2` (per user specification). This shifts one Strategy course from S1 pool to S2 pool.
## Risks / Trade-offs
- **STR S1 pool shrinks from 10 to 9** — Minor reduction in S1-qualifying courses. No mitigation needed; this reflects the actual curriculum change.
- **LCM and MGT lose coverage in sum2** — These specializations no longer have a qualifying course in Summer Set 2. This accurately reflects the new course's different focus.

View File

@@ -0,0 +1,30 @@
## Why
The cancelled course "Managing Growing Companies" in Summer Elective Set 2 needs to be replaced with a new course "Innovation and Design" that counts towards different specializations (BRM, ENT, MKT, STR-S2 instead of ENT, LCM, MGT, STR-S1).
## What Changes
- Remove the cancelled course entry `sum2-managing-growing` ("Managing Growing Companies") from the course data
- Add a new course `sum2-innovation-design` ("Innovation and Design") in Summer Set 2 with qualifications: Brand Management (BRM), Entrepreneurship and Innovation (ENT), Marketing (MKT), Strategy (STR, S2 marker)
- Update test assertions to reflect changed specialization counts and Strategy marker counts
## Capabilities
### New Capabilities
_None — this is a data change, not a new capability._
### Modified Capabilities
_None — no spec-level behavior changes, only course data._
## Impact
- `app/src/data/courses.ts` — replace cancelled course entry with new course
- `app/src/data/__tests__/data.test.ts` — update assertions:
- STR S1 count: 10 → 9
- STR S2 count: 7 → 8
- BRM across sets: 6 → 7
- MKT across sets: 7 → 8
- LCM across sets: 9 → 8
- MGT across sets: 11 → 10

View File

@@ -0,0 +1,18 @@
## ADDED Requirements
### Requirement: Innovation and Design course in Summer Set 2
The system SHALL include "Innovation and Design" as an active course in Summer Elective Set 2 (`sum2`), qualifying for Brand Management (BRM), Entrepreneurship and Innovation (ENT), Marketing (MKT), and Strategy (STR, S2 marker).
#### Scenario: Course appears in Summer Set 2
- **WHEN** the user views Summer Elective Set 2
- **THEN** "Innovation and Design" appears as a selectable course
#### Scenario: Course qualifies for correct specializations
- **WHEN** the user selects "Innovation and Design"
- **THEN** it contributes credits towards BRM, ENT, MKT, and STR (S2)
## REMOVED Requirements
### Requirement: Managing Growing Companies course
**Reason**: Course has been cancelled by the program and replaced by "Innovation and Design"
**Migration**: No migration needed — the cancelled course was already excluded from solver computations

View File

@@ -0,0 +1,17 @@
## 1. Course Data
- [x] 1.1 Replace `sum2-managing-growing` entry in `app/src/data/courses.ts` with new `sum2-innovation-design` course: name "Innovation and Design", setId "sum2", qualifications BRM (standard), ENT (standard), MKT (standard), STR (S2)
## 2. Test Assertions
- [x] 2.1 Update STR marker counts in `app/src/data/__tests__/data.test.ts`: S1 from 10 to 9, S2 from 7 to 8
- [x] 2.2 Update per-specialization "across sets" counts: BRM 6→7, MKT 7→8, LCM 9→8, MGT 11→10
## 3. Version and Changelog
- [x] 3.1 Bump version to `1.1.1` and update date in `app/vite.config.ts` (`__APP_VERSION__` and `__APP_VERSION_DATE__`)
- [x] 3.2 Add v1.1.1 entry to `CHANGELOG.md` documenting the course replacement
## 4. Verification
- [x] 4.1 Run test suite and confirm all tests pass