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:
2026-03-13 16:11:56 -04:00
parent 5c598d1fc6
commit 8b887f7750
14 changed files with 156 additions and 39 deletions

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 };
}