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
This commit is contained in:
@@ -1,5 +1,13 @@
|
|||||||
# Changelog
|
# 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
|
## v1.0.0 — 2026-02-28
|
||||||
|
|
||||||
Initial release of the EMBA Specialization Solver.
|
Initial release of the EMBA Specialization Solver.
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ function App() {
|
|||||||
treeLoading,
|
treeLoading,
|
||||||
openSetIds,
|
openSetIds,
|
||||||
selectedCourseIds,
|
selectedCourseIds,
|
||||||
|
disabledCourseIds,
|
||||||
|
excludedCourseIds,
|
||||||
reorder,
|
reorder,
|
||||||
setMode,
|
setMode,
|
||||||
pinCourse,
|
pinCourse,
|
||||||
@@ -30,8 +32,8 @@ function App() {
|
|||||||
// Compute alternative mode result for comparison
|
// Compute alternative mode result for comparison
|
||||||
const altMode = state.mode === 'maximize-count' ? 'priority-order' : 'maximize-count';
|
const altMode = state.mode === 'maximize-count' ? 'priority-order' : 'maximize-count';
|
||||||
const altResult = useMemo(
|
const altResult = useMemo(
|
||||||
() => optimize(selectedCourseIds, state.ranking, openSetIds, altMode),
|
() => optimize(selectedCourseIds, state.ranking, openSetIds, altMode, excludedCourseIds),
|
||||||
[selectedCourseIds, state.ranking, openSetIds, altMode],
|
[selectedCourseIds, state.ranking, openSetIds, altMode, excludedCourseIds],
|
||||||
);
|
);
|
||||||
|
|
||||||
const isMobile = breakpoint === 'mobile';
|
const isMobile = breakpoint === 'mobile';
|
||||||
@@ -105,7 +107,7 @@ function App() {
|
|||||||
<h1 style={{ fontSize: '20px', marginBottom: '2px', color: '#111' }}>
|
<h1 style={{ fontSize: '20px', marginBottom: '2px', color: '#111' }}>
|
||||||
EMBA Specialization Solver
|
EMBA Specialization Solver
|
||||||
</h1>
|
</h1>
|
||||||
<div style={{ fontSize: '11px', color: '#999', marginBottom: '12px' }}>v{__APP_VERSION__}</div>
|
<div style={{ fontSize: '11px', color: '#999', marginBottom: '12px' }}>v{__APP_VERSION__} ({__APP_VERSION_DATE__})</div>
|
||||||
|
|
||||||
<ModeToggle mode={state.mode} onSetMode={setMode} />
|
<ModeToggle mode={state.mode} onSetMode={setMode} />
|
||||||
|
|
||||||
@@ -129,6 +131,7 @@ function App() {
|
|||||||
pinnedCourses={state.pinnedCourses}
|
pinnedCourses={state.pinnedCourses}
|
||||||
treeResults={treeResults}
|
treeResults={treeResults}
|
||||||
treeLoading={treeLoading}
|
treeLoading={treeLoading}
|
||||||
|
disabledCourseIds={disabledCourseIds}
|
||||||
onPin={pinCourse}
|
onPin={pinCourse}
|
||||||
onUnpin={unpinCourse}
|
onUnpin={unpinCourse}
|
||||||
onClearAll={clearAll}
|
onClearAll={clearAll}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ interface CourseSelectionProps {
|
|||||||
pinnedCourses: Record<string, string | null>;
|
pinnedCourses: Record<string, string | null>;
|
||||||
treeResults: SetAnalysis[];
|
treeResults: SetAnalysis[];
|
||||||
treeLoading: boolean;
|
treeLoading: boolean;
|
||||||
|
disabledCourseIds: Set<string>;
|
||||||
onPin: (setId: string, courseId: string) => void;
|
onPin: (setId: string, courseId: string) => void;
|
||||||
onUnpin: (setId: string) => void;
|
onUnpin: (setId: string) => void;
|
||||||
onClearAll: () => void;
|
onClearAll: () => void;
|
||||||
@@ -27,6 +28,7 @@ function ElectiveSet({
|
|||||||
pinnedCourseId,
|
pinnedCourseId,
|
||||||
analysis,
|
analysis,
|
||||||
loading,
|
loading,
|
||||||
|
disabledCourseIds,
|
||||||
onPin,
|
onPin,
|
||||||
onUnpin,
|
onUnpin,
|
||||||
}: {
|
}: {
|
||||||
@@ -35,6 +37,7 @@ function ElectiveSet({
|
|||||||
pinnedCourseId: string | null | undefined;
|
pinnedCourseId: string | null | undefined;
|
||||||
analysis?: SetAnalysis;
|
analysis?: SetAnalysis;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
|
disabledCourseIds: Set<string>;
|
||||||
onPin: (courseId: string) => void;
|
onPin: (courseId: string) => void;
|
||||||
onUnpin: () => void;
|
onUnpin: () => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -101,23 +104,47 @@ function ElectiveSet({
|
|||||||
}}>
|
}}>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||||
{courses.map((course) => {
|
{courses.map((course) => {
|
||||||
|
const isCancelled = !!course.cancelled;
|
||||||
|
const isDisabled = disabledCourseIds.has(course.id);
|
||||||
|
const isUnavailable = isCancelled || isDisabled;
|
||||||
const ceiling = ceilingMap.get(course.id);
|
const ceiling = ceilingMap.get(course.id);
|
||||||
const reqFor = requiredForSpec[course.id];
|
const reqFor = requiredForSpec[course.id];
|
||||||
const showSkeleton = loading && !analysis;
|
const showSkeleton = loading && !analysis;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={course.id}
|
key={course.id}
|
||||||
onClick={() => onPin(course.id)}
|
onClick={isUnavailable ? undefined : () => onPin(course.id)}
|
||||||
|
disabled={isUnavailable}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex', flexDirection: 'column', alignItems: 'stretch',
|
display: 'flex', flexDirection: 'column', alignItems: 'stretch',
|
||||||
textAlign: 'left', padding: '6px 10px',
|
textAlign: 'left', padding: '6px 10px',
|
||||||
border: '1px solid #e5e7eb', borderRadius: '4px',
|
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' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '8px' }}>
|
||||||
<span style={{ flex: 1 }}>{course.name}</span>
|
<span style={{
|
||||||
{showSkeleton ? (
|
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
|
<span
|
||||||
style={{
|
style={{
|
||||||
display: 'inline-block',
|
display: 'inline-block',
|
||||||
@@ -129,7 +156,7 @@ function ElectiveSet({
|
|||||||
animation: 'skeleton-pulse 1.5s ease-in-out infinite',
|
animation: 'skeleton-pulse 1.5s ease-in-out infinite',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : ceiling ? (
|
) : !isUnavailable && ceiling ? (
|
||||||
<span style={{
|
<span style={{
|
||||||
fontSize: '11px', whiteSpace: 'nowrap', fontWeight: 600,
|
fontSize: '11px', whiteSpace: 'nowrap', fontWeight: 600,
|
||||||
color: ceiling.ceilingCount >= 3 ? '#16a34a' : ceiling.ceilingCount >= 2 ? '#2563eb' : '#666',
|
color: ceiling.ceilingCount >= 3 ? '#16a34a' : ceiling.ceilingCount >= 2 ? '#2563eb' : '#666',
|
||||||
@@ -143,7 +170,7 @@ function ElectiveSet({
|
|||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{reqFor && (
|
{reqFor && !isUnavailable && (
|
||||||
<span style={{ fontSize: '11px', color: '#92400e', marginTop: '2px' }}>
|
<span style={{ fontSize: '11px', color: '#92400e', marginTop: '2px' }}>
|
||||||
Required for {reqFor.join(', ')}
|
Required for {reqFor.join(', ')}
|
||||||
</span>
|
</span>
|
||||||
@@ -159,7 +186,7 @@ function ElectiveSet({
|
|||||||
|
|
||||||
const skeletonStyle = `@keyframes skeleton-pulse { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }`;
|
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 terms: Term[] = ['Spring', 'Summer', 'Fall'];
|
||||||
const hasPinned = Object.keys(pinnedCourses).length > 0;
|
const hasPinned = Object.keys(pinnedCourses).length > 0;
|
||||||
|
|
||||||
@@ -200,6 +227,7 @@ export function CourseSelection({ pinnedCourses, treeResults, treeLoading, onPin
|
|||||||
pinnedCourseId={pinnedCourses[set.id]}
|
pinnedCourseId={pinnedCourses[set.id]}
|
||||||
analysis={treeBySet.get(set.id)}
|
analysis={treeBySet.get(set.id)}
|
||||||
loading={treeLoading}
|
loading={treeLoading}
|
||||||
|
disabledCourseIds={disabledCourseIds}
|
||||||
onPin={(courseId) => onPin(set.id, courseId)}
|
onPin={(courseId) => onPin(set.id, courseId)}
|
||||||
onUnpin={() => onUnpin(set.id)}
|
onUnpin={() => onUnpin(set.id)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -33,6 +33,12 @@ function CreditBar({ allocated, potential, threshold }: { allocated: number; pot
|
|||||||
const allocPct = Math.min((allocated / maxWidth) * 100, 100);
|
const allocPct = Math.min((allocated / maxWidth) * 100, 100);
|
||||||
const potentialPct = Math.min((potential / 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 (
|
return (
|
||||||
<div style={{ position: 'relative', height: '6px', background: '#e5e7eb', borderRadius: '3px', marginTop: '4px' }}>
|
<div style={{ position: 'relative', height: '6px', background: '#e5e7eb', borderRadius: '3px', marginTop: '4px' }}>
|
||||||
{potential > allocated && (
|
{potential > allocated && (
|
||||||
@@ -51,11 +57,22 @@ function CreditBar({ allocated, potential, threshold }: { allocated: number; pot
|
|||||||
borderRadius: '3px', transition: 'width 300ms ease-out',
|
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
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute', left: `${(threshold / maxWidth) * 100}%`, top: '-2px',
|
position: 'absolute', left: `${(threshold / maxWidth) * 100}%`, top: '-2px',
|
||||||
width: '2px', height: '10px', background: '#666',
|
width: '2px', height: '10px', background: '#666',
|
||||||
transition: 'left 300ms ease-out',
|
zIndex: 2, transition: 'left 300ms ease-out',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ export const COURSES: Course[] = [
|
|||||||
// === Summer Elective Set 2 ===
|
// === Summer Elective Set 2 ===
|
||||||
{
|
{
|
||||||
id: 'sum2-managing-growing', name: 'Managing Growing Companies', setId: 'sum2',
|
id: 'sum2-managing-growing', name: 'Managing Growing Companies', setId: 'sum2',
|
||||||
|
cancelled: true,
|
||||||
qualifications: [
|
qualifications: [
|
||||||
{ specId: 'ENT', marker: 'standard' }, { specId: 'LCM', marker: 'standard' },
|
{ specId: 'ENT', marker: 'standard' }, { specId: 'LCM', marker: 'standard' },
|
||||||
{ specId: 'MGT', marker: 'standard' }, { specId: 'STR', marker: 'S1' },
|
{ specId: 'MGT', marker: 'standard' }, { specId: 'STR', marker: 'S1' },
|
||||||
|
|||||||
@@ -38,3 +38,15 @@ export const setIdByCourse: Record<string, string> = {};
|
|||||||
for (const course of COURSES) {
|
for (const course of COURSES) {
|
||||||
setIdByCourse[course.id] = course.setId;
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export interface Course {
|
|||||||
name: string;
|
name: string;
|
||||||
setId: string;
|
setId: string;
|
||||||
qualifications: Qualification[];
|
qualifications: Qualification[];
|
||||||
|
cancelled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Specialization {
|
export interface Specialization {
|
||||||
|
|||||||
@@ -30,13 +30,14 @@ function computeCeiling(
|
|||||||
otherOpenSetIds: string[],
|
otherOpenSetIds: string[],
|
||||||
ranking: string[],
|
ranking: string[],
|
||||||
mode: OptimizationMode,
|
mode: OptimizationMode,
|
||||||
|
excludedCourseIds?: Set<string>,
|
||||||
): { count: number; specs: string[] } {
|
): { count: number; specs: string[] } {
|
||||||
const fn = mode === 'maximize-count' ? maximizeCount : priorityOrder;
|
const fn = mode === 'maximize-count' ? maximizeCount : priorityOrder;
|
||||||
|
|
||||||
if (otherOpenSetIds.length === 0) {
|
if (otherOpenSetIds.length === 0) {
|
||||||
// No other open sets — just solve with this choice added
|
// No other open sets — just solve with this choice added
|
||||||
const selected = [...basePinnedCourses, chosenCourseId];
|
const selected = [...basePinnedCourses, chosenCourseId];
|
||||||
const result = fn(selected, ranking, []);
|
const result = fn(selected, ranking, [], excludedCourseIds);
|
||||||
return { count: result.achieved.length, specs: result.achieved };
|
return { count: result.achieved.length, specs: result.achieved };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,7 +51,7 @@ function computeCeiling(
|
|||||||
|
|
||||||
if (setIndex >= otherOpenSetIds.length) {
|
if (setIndex >= otherOpenSetIds.length) {
|
||||||
const selected = [...basePinnedCourses, chosenCourseId, ...accumulated];
|
const selected = [...basePinnedCourses, chosenCourseId, ...accumulated];
|
||||||
const result = fn(selected, ranking, []);
|
const result = fn(selected, ranking, [], excludedCourseIds);
|
||||||
if (result.achieved.length > bestCount) {
|
if (result.achieved.length > bestCount) {
|
||||||
bestCount = result.achieved.length;
|
bestCount = result.achieved.length;
|
||||||
bestSpecs = result.achieved;
|
bestSpecs = result.achieved;
|
||||||
@@ -61,6 +62,7 @@ function computeCeiling(
|
|||||||
const setId = otherOpenSetIds[setIndex];
|
const setId = otherOpenSetIds[setIndex];
|
||||||
const courses = coursesBySet[setId];
|
const courses = coursesBySet[setId];
|
||||||
for (const course of courses) {
|
for (const course of courses) {
|
||||||
|
if (excludedCourseIds?.has(course.id)) continue;
|
||||||
enumerate(setIndex + 1, [...accumulated, course.id]);
|
enumerate(setIndex + 1, [...accumulated, course.id]);
|
||||||
if (bestCount >= 3) return;
|
if (bestCount >= 3) return;
|
||||||
}
|
}
|
||||||
@@ -91,6 +93,7 @@ export function analyzeDecisionTree(
|
|||||||
ranking: string[],
|
ranking: string[],
|
||||||
mode: OptimizationMode,
|
mode: OptimizationMode,
|
||||||
onSetComplete?: (analysis: SetAnalysis) => void,
|
onSetComplete?: (analysis: SetAnalysis) => void,
|
||||||
|
excludedCourseIds?: Set<string>,
|
||||||
): SetAnalysis[] {
|
): SetAnalysis[] {
|
||||||
if (openSetIds.length > MAX_OPEN_SETS_FOR_ENUMERATION) {
|
if (openSetIds.length > MAX_OPEN_SETS_FOR_ENUMERATION) {
|
||||||
// Fallback: return empty analyses (caller uses upper bounds instead)
|
// Fallback: return empty analyses (caller uses upper bounds instead)
|
||||||
@@ -107,21 +110,24 @@ export function analyzeDecisionTree(
|
|||||||
const otherOpenSets = openSetIds.filter((id) => id !== setId);
|
const otherOpenSets = openSetIds.filter((id) => id !== setId);
|
||||||
const courses = coursesBySet[setId];
|
const courses = coursesBySet[setId];
|
||||||
|
|
||||||
const choices: ChoiceOutcome[] = courses.map((course) => {
|
const choices: ChoiceOutcome[] = courses
|
||||||
const ceiling = computeCeiling(
|
.filter((course) => !excludedCourseIds?.has(course.id))
|
||||||
pinnedCourseIds,
|
.map((course) => {
|
||||||
course.id,
|
const ceiling = computeCeiling(
|
||||||
otherOpenSets,
|
pinnedCourseIds,
|
||||||
ranking,
|
course.id,
|
||||||
mode,
|
otherOpenSets,
|
||||||
);
|
ranking,
|
||||||
return {
|
mode,
|
||||||
courseId: course.id,
|
excludedCourseIds,
|
||||||
courseName: course.name,
|
);
|
||||||
ceilingCount: ceiling.count,
|
return {
|
||||||
ceilingSpecs: ceiling.specs,
|
courseId: course.id,
|
||||||
};
|
courseName: course.name,
|
||||||
});
|
ceilingCount: ceiling.count,
|
||||||
|
ceilingSpecs: ceiling.specs,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const impact = variance(choices.map((c) => c.ceilingCount));
|
const impact = variance(choices.map((c) => c.ceilingCount));
|
||||||
const analysis: SetAnalysis = { setId, setName: set.name, impact, choices };
|
const analysis: SetAnalysis = { setId, setName: set.name, impact, choices };
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ export function checkFeasibility(
|
|||||||
export function preFilterCandidates(
|
export function preFilterCandidates(
|
||||||
selectedCourseIds: string[],
|
selectedCourseIds: string[],
|
||||||
openSetIds: string[],
|
openSetIds: string[],
|
||||||
|
excludedCourseIds?: Set<string>,
|
||||||
): string[] {
|
): string[] {
|
||||||
const selectedSet = new Set(selectedCourseIds);
|
const selectedSet = new Set(selectedCourseIds);
|
||||||
const openSetSet = new Set(openSetIds);
|
const openSetSet = new Set(openSetIds);
|
||||||
@@ -128,6 +129,7 @@ export function preFilterCandidates(
|
|||||||
let potential = 0;
|
let potential = 0;
|
||||||
const countedSets = new Set<string>();
|
const countedSets = new Set<string>();
|
||||||
for (const e of entries) {
|
for (const e of entries) {
|
||||||
|
if (excludedCourseIds?.has(e.courseId)) continue;
|
||||||
const setId = setIdByCourse[e.courseId];
|
const setId = setIdByCourse[e.courseId];
|
||||||
if (selectedSet.has(e.courseId)) {
|
if (selectedSet.has(e.courseId)) {
|
||||||
if (!countedSets.has(setId)) {
|
if (!countedSets.has(setId)) {
|
||||||
@@ -165,6 +167,7 @@ export function enumerateS2Choices(selectedCourseIds: string[]): (string | null)
|
|||||||
export function computeUpperBounds(
|
export function computeUpperBounds(
|
||||||
selectedCourseIds: string[],
|
selectedCourseIds: string[],
|
||||||
openSetIds: string[],
|
openSetIds: string[],
|
||||||
|
excludedCourseIds?: Set<string>,
|
||||||
): Record<string, number> {
|
): Record<string, number> {
|
||||||
const selectedSet = new Set(selectedCourseIds);
|
const selectedSet = new Set(selectedCourseIds);
|
||||||
const openSetSet = new Set(openSetIds);
|
const openSetSet = new Set(openSetIds);
|
||||||
@@ -175,6 +178,7 @@ export function computeUpperBounds(
|
|||||||
let potential = 0;
|
let potential = 0;
|
||||||
const countedSets = new Set<string>();
|
const countedSets = new Set<string>();
|
||||||
for (const e of entries) {
|
for (const e of entries) {
|
||||||
|
if (excludedCourseIds?.has(e.courseId)) continue;
|
||||||
const setId = setIdByCourse[e.courseId];
|
const setId = setIdByCourse[e.courseId];
|
||||||
if (selectedSet.has(e.courseId)) {
|
if (selectedSet.has(e.courseId)) {
|
||||||
if (!countedSets.has(setId)) {
|
if (!countedSets.has(setId)) {
|
||||||
|
|||||||
@@ -56,8 +56,9 @@ export function maximizeCount(
|
|||||||
selectedCourseIds: string[],
|
selectedCourseIds: string[],
|
||||||
ranking: string[],
|
ranking: string[],
|
||||||
openSetIds: string[],
|
openSetIds: string[],
|
||||||
|
excludedCourseIds?: Set<string>,
|
||||||
): { achieved: string[]; allocations: Record<string, Record<string, number>> } {
|
): { 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)
|
// Only check specs that can be achieved from selected courses alone (not open sets)
|
||||||
// Filter to candidates that have qualifying selected courses
|
// Filter to candidates that have qualifying selected courses
|
||||||
@@ -108,8 +109,9 @@ export function priorityOrder(
|
|||||||
selectedCourseIds: string[],
|
selectedCourseIds: string[],
|
||||||
ranking: string[],
|
ranking: string[],
|
||||||
openSetIds: string[],
|
openSetIds: string[],
|
||||||
|
excludedCourseIds?: Set<string>,
|
||||||
): { achieved: string[]; allocations: Record<string, Record<string, number>> } {
|
): { 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
|
// Only consider specs that have qualifying selected courses
|
||||||
const withSelectedCourses = new Set(
|
const withSelectedCourses = new Set(
|
||||||
@@ -145,11 +147,12 @@ export function determineStatuses(
|
|||||||
selectedCourseIds: string[],
|
selectedCourseIds: string[],
|
||||||
openSetIds: string[],
|
openSetIds: string[],
|
||||||
achieved: string[],
|
achieved: string[],
|
||||||
|
excludedCourseIds?: Set<string>,
|
||||||
): Record<string, SpecStatus> {
|
): Record<string, SpecStatus> {
|
||||||
const achievedSet = new Set(achieved);
|
const achievedSet = new Set(achieved);
|
||||||
const selectedSet = new Set(selectedCourseIds);
|
const selectedSet = new Set(selectedCourseIds);
|
||||||
const openSetSet = new Set(openSetIds);
|
const openSetSet = new Set(openSetIds);
|
||||||
const upperBounds = computeUpperBounds(selectedCourseIds, openSetIds);
|
const upperBounds = computeUpperBounds(selectedCourseIds, openSetIds, excludedCourseIds);
|
||||||
const statuses: Record<string, SpecStatus> = {};
|
const statuses: Record<string, SpecStatus> = {};
|
||||||
|
|
||||||
for (const spec of SPECIALIZATIONS) {
|
for (const spec of SPECIALIZATIONS) {
|
||||||
@@ -189,11 +192,12 @@ export function optimize(
|
|||||||
ranking: string[],
|
ranking: string[],
|
||||||
openSetIds: string[],
|
openSetIds: string[],
|
||||||
mode: 'maximize-count' | 'priority-order',
|
mode: 'maximize-count' | 'priority-order',
|
||||||
|
excludedCourseIds?: Set<string>,
|
||||||
): AllocationResult {
|
): AllocationResult {
|
||||||
const fn = mode === 'maximize-count' ? maximizeCount : priorityOrder;
|
const fn = mode === 'maximize-count' ? maximizeCount : priorityOrder;
|
||||||
const { achieved, allocations } = fn(selectedCourseIds, ranking, openSetIds);
|
const { achieved, allocations } = fn(selectedCourseIds, ranking, openSetIds, excludedCourseIds);
|
||||||
const statuses = determineStatuses(selectedCourseIds, openSetIds, achieved);
|
const statuses = determineStatuses(selectedCourseIds, openSetIds, achieved, excludedCourseIds);
|
||||||
const upperBounds = computeUpperBounds(selectedCourseIds, openSetIds);
|
const upperBounds = computeUpperBounds(selectedCourseIds, openSetIds, excludedCourseIds);
|
||||||
|
|
||||||
return { achieved, allocations, statuses, upperBounds };
|
return { achieved, allocations, statuses, upperBounds };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { OptimizationMode, AllocationResult } from '../data/types';
|
|||||||
import { optimize } from '../solver/optimizer';
|
import { optimize } from '../solver/optimizer';
|
||||||
import type { SetAnalysis } from '../solver/decisionTree';
|
import type { SetAnalysis } from '../solver/decisionTree';
|
||||||
import type { WorkerRequest, WorkerResponse } from '../workers/decisionTree.worker';
|
import type { WorkerRequest, WorkerResponse } from '../workers/decisionTree.worker';
|
||||||
|
import { cancelledCourseIds, courseIdsByName, courseById } from '../data/lookups';
|
||||||
import DecisionTreeWorker from '../workers/decisionTree.worker?worker';
|
import DecisionTreeWorker from '../workers/decisionTree.worker?worker';
|
||||||
|
|
||||||
const STORAGE_KEY = 'emba-solver-state';
|
const STORAGE_KEY = 'emba-solver-state';
|
||||||
@@ -88,10 +89,32 @@ export function useAppState() {
|
|||||||
[state.pinnedCourses],
|
[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)
|
// Main-thread optimization (instant)
|
||||||
const optimizationResult: AllocationResult = useMemo(
|
const optimizationResult: AllocationResult = useMemo(
|
||||||
() => optimize(selectedCourseIds, state.ranking, openSetIds, state.mode),
|
() => optimize(selectedCourseIds, state.ranking, openSetIds, state.mode, excludedCourseIds),
|
||||||
[selectedCourseIds, state.ranking, openSetIds, state.mode],
|
[selectedCourseIds, state.ranking, openSetIds, state.mode, excludedCourseIds],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Web Worker decision tree (debounced)
|
// Web Worker decision tree (debounced)
|
||||||
@@ -132,6 +155,7 @@ export function useAppState() {
|
|||||||
openSetIds,
|
openSetIds,
|
||||||
ranking: state.ranking,
|
ranking: state.ranking,
|
||||||
mode: state.mode,
|
mode: state.mode,
|
||||||
|
excludedCourseIds: [...excludedCourseIds],
|
||||||
};
|
};
|
||||||
worker.postMessage(request);
|
worker.postMessage(request);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -147,7 +171,7 @@ export function useAppState() {
|
|||||||
workerRef.current = null;
|
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 reorder = useCallback((ranking: string[]) => dispatch({ type: 'reorder', ranking }), []);
|
||||||
const setMode = useCallback((mode: OptimizationMode) => dispatch({ type: 'setMode', mode }), []);
|
const setMode = useCallback((mode: OptimizationMode) => dispatch({ type: 'setMode', mode }), []);
|
||||||
@@ -162,6 +186,8 @@ export function useAppState() {
|
|||||||
treeLoading,
|
treeLoading,
|
||||||
openSetIds,
|
openSetIds,
|
||||||
selectedCourseIds,
|
selectedCourseIds,
|
||||||
|
disabledCourseIds,
|
||||||
|
excludedCourseIds,
|
||||||
reorder,
|
reorder,
|
||||||
setMode,
|
setMode,
|
||||||
pinCourse,
|
pinCourse,
|
||||||
|
|||||||
1
app/src/vite-env.d.ts
vendored
1
app/src/vite-env.d.ts
vendored
@@ -1,3 +1,4 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
declare const __APP_VERSION__: string;
|
declare const __APP_VERSION__: string;
|
||||||
|
declare const __APP_VERSION_DATE__: string;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export interface WorkerRequest {
|
|||||||
openSetIds: string[];
|
openSetIds: string[];
|
||||||
ranking: string[];
|
ranking: string[];
|
||||||
mode: OptimizationMode;
|
mode: OptimizationMode;
|
||||||
|
excludedCourseIds?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WorkerResponse {
|
export interface WorkerResponse {
|
||||||
@@ -16,7 +17,10 @@ export interface WorkerResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
self.onmessage = (e: MessageEvent<WorkerRequest>) => {
|
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(
|
const analyses = analyzeDecisionTree(
|
||||||
pinnedCourseIds,
|
pinnedCourseIds,
|
||||||
@@ -28,6 +32,7 @@ self.onmessage = (e: MessageEvent<WorkerRequest>) => {
|
|||||||
const response: WorkerResponse = { type: 'setComplete', analysis };
|
const response: WorkerResponse = { type: 'setComplete', analysis };
|
||||||
self.postMessage(response);
|
self.postMessage(response);
|
||||||
},
|
},
|
||||||
|
excludedSet,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Final result with sorted analyses
|
// Final result with sorted analyses
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import react from '@vitejs/plugin-react'
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
define: {
|
define: {
|
||||||
__APP_VERSION__: JSON.stringify('1.0.0'),
|
__APP_VERSION__: JSON.stringify('1.1.0'),
|
||||||
|
__APP_VERSION_DATE__: JSON.stringify('2026-03-13'),
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
allowedHosts: ['soos'],
|
allowedHosts: ['soos'],
|
||||||
|
|||||||
Reference in New Issue
Block a user