v1.5.0: External credits per specialization

Students can now record credits earned in courses taken outside the J27
program via an inline editable amber chip on each spec card. Values flow
through the LP (per-spec demand reduces by external amount), upper-bound
math, decision-tree search, and the credit bar visualization. The 9-credit
threshold and the 3-spec achievement cap are unchanged; required-course
gates remain authoritative — external credits never satisfy them.
This commit is contained in:
2026-05-10 11:47:22 -04:00
parent 2ebfb9d2ec
commit 3a5ebaa17a
17 changed files with 893 additions and 72 deletions
+7 -2
View File
@@ -29,6 +29,7 @@ function App() {
setMode,
pinCourse,
unpinCourse,
setExternalCredit,
clearAll,
adoptPlan,
} = useAppState();
@@ -38,8 +39,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, excludedCourseIds),
[selectedCourseIds, state.ranking, openSetIds, altMode, excludedCourseIds],
() => optimize(selectedCourseIds, state.ranking, openSetIds, altMode, excludedCourseIds, state.externalCredits),
[selectedCourseIds, state.ranking, openSetIds, altMode, excludedCourseIds, state.externalCredits],
);
const isMobile = breakpoint === 'mobile';
@@ -114,7 +115,9 @@ function App() {
<SpecializationRanking
ranking={state.ranking}
result={optimizationResult}
externalCredits={state.externalCredits}
onReorder={reorder}
onSetExternalCredit={setExternalCredit}
/>
</div>
)}
@@ -180,7 +183,9 @@ function App() {
<SpecializationRanking
ranking={state.ranking}
result={optimizationResult}
externalCredits={state.externalCredits}
onReorder={reorder}
onSetExternalCredit={setExternalCredit}
headerSlot={<CreditLegend />}
/>
+187 -35
View File
@@ -31,10 +31,15 @@ export const STATUS_STYLES: Record<SpecStatus, { bg: string; color: string; labe
unreachable: { bg: '#f3f4f6', color: '#9ca3af', label: 'Unreachable' },
};
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);
const EXTERNAL_COLOR = '#f59e0b';
function CreditBar({ allocated, potential, threshold, external = 0 }: { allocated: number; potential: number; threshold: number; external?: number }) {
const maxWidth = Math.max(potential + external, threshold);
const externalPct = Math.min((external / maxWidth) * 100, 100);
const externalEnd = externalPct;
const allocatedEnd = Math.min(((external + allocated) / maxWidth) * 100, 100);
const potentialEnd = Math.min(((external + potential) / maxWidth) * 100, 100);
const isAchieved = (allocated + external) >= threshold;
// Generate tick marks at 2.5 credit intervals
const ticks: number[] = [];
@@ -47,19 +52,28 @@ function CreditBar({ allocated, potential, threshold }: { allocated: number; pot
{potential > allocated && (
<div
style={{
position: 'absolute', left: 0, top: 0, height: '100%',
width: `${potentialPct}%`, background: '#bfdbfe', borderRadius: '3px',
transition: 'width 300ms ease-out',
position: 'absolute', left: `${externalEnd}%`, top: 0, height: '100%',
width: `${Math.max(0, potentialEnd - externalEnd)}%`, background: '#bfdbfe', borderRadius: '3px',
transition: 'width 300ms ease-out, left 300ms ease-out',
}}
/>
)}
<div
style={{
position: 'absolute', left: 0, top: 0, height: '100%',
width: `${allocPct}%`, background: allocated >= threshold ? '#22c55e' : '#3b82f6',
borderRadius: '3px', transition: 'width 300ms ease-out',
position: 'absolute', left: `${externalEnd}%`, top: 0, height: '100%',
width: `${Math.max(0, allocatedEnd - externalEnd)}%`, background: isAchieved ? '#22c55e' : '#3b82f6',
borderRadius: '3px', transition: 'width 300ms ease-out, left 300ms ease-out',
}}
/>
{external > 0 && (
<div
style={{
position: 'absolute', left: 0, top: 0, height: '100%',
width: `${externalPct}%`, background: EXTERNAL_COLOR,
borderRadius: '3px', transition: 'width 300ms ease-out',
}}
/>
)}
{/* Tick marks at 2.5 credit intervals — rendered above bar fills */}
{ticks.map((t) => (
<div
@@ -82,7 +96,7 @@ function CreditBar({ allocated, potential, threshold }: { allocated: number; pot
);
}
function AllocationBreakdown({ specId, allocations }: { specId: string; allocations: Record<string, Record<string, number>> }) {
function AllocationBreakdown({ specId, allocations, external = 0 }: { specId: string; allocations: Record<string, Record<string, number>>; external?: number }) {
const contributions: { courseName: string; credits: number }[] = [];
for (const [courseId, specAlloc] of Object.entries(allocations)) {
const credits = specAlloc[specId];
@@ -91,10 +105,16 @@ function AllocationBreakdown({ specId, allocations }: { specId: string; allocati
contributions.push({ courseName: course?.name ?? courseId, credits });
}
}
if (contributions.length === 0) return null;
if (contributions.length === 0 && external <= 0) return null;
return (
<div style={{ marginTop: '6px', paddingLeft: '28px', fontSize: '12px', color: '#555' }}>
{external > 0 && (
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '2px 0', color: EXTERNAL_COLOR, fontStyle: 'italic' }}>
<span>External</span>
<span style={{ fontWeight: 600 }}>{external.toFixed(1)}</span>
</div>
)}
{contributions.map((c, i) => (
<div key={i} style={{ display: 'flex', justifyContent: 'space-between', padding: '2px 0' }}>
<span>{c.courseName}</span>
@@ -105,6 +125,101 @@ function AllocationBreakdown({ specId, allocations }: { specId: string; allocati
);
}
function ExternalCreditChip({ value, onChange, compact = false }: { value: number; onChange: (next: number) => void; compact?: boolean }) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value > 0 ? String(value) : '');
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!editing) setDraft(value > 0 ? String(value) : '');
}, [value, editing]);
useEffect(() => {
if (editing && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [editing]);
function commit() {
const parsed = parseFloat(draft);
const next = Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
if (next !== value) onChange(next);
setEditing(false);
}
function cancel() {
setDraft(value > 0 ? String(value) : '');
setEditing(false);
}
const baseStyle: React.CSSProperties = {
fontSize: compact ? '9px' : '10px',
fontVariantNumeric: 'tabular-nums',
padding: compact ? '1px 4px' : '2px 6px',
borderRadius: '8px',
lineHeight: 1.3,
whiteSpace: 'nowrap',
cursor: 'pointer',
userSelect: 'none',
};
if (editing) {
return (
<input
ref={inputRef}
type="number"
inputMode="decimal"
step="0.5"
min="0"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === 'Enter') commit();
else if (e.key === 'Escape') cancel();
e.stopPropagation();
}}
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
aria-label="External credits"
style={{
...baseStyle,
width: compact ? '38px' : '46px',
border: `1px solid ${EXTERNAL_COLOR}`,
background: '#fff',
color: '#92400e',
cursor: 'text',
}}
/>
);
}
const hasValue = value > 0;
return (
<span
role="button"
tabIndex={0}
aria-label={hasValue ? `External credits: ${value}` : 'Add external credits'}
title={hasValue ? `External credits: ${value.toFixed(1)}` : 'Add external credits'}
onClick={(e) => { e.stopPropagation(); setEditing(true); }}
onPointerDown={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setEditing(true); }
}}
style={{
...baseStyle,
background: hasValue ? `${EXTERNAL_COLOR}1f` : 'transparent',
color: hasValue ? '#92400e' : '#94a3b8',
border: `1px dashed ${hasValue ? EXTERNAL_COLOR : '#cbd5e1'}`,
fontWeight: hasValue ? 600 : 500,
}}
>
{hasValue ? `+${value.toFixed(1)}` : '+ ext'}
</span>
);
}
interface SortableItemProps {
id: string;
rank: number;
@@ -113,14 +228,16 @@ interface SortableItemProps {
status: SpecStatus;
allocated: number;
potential: number;
external: number;
isExpanded: boolean;
allocations: Record<string, Record<string, number>>;
onMoveUp: () => void;
onMoveDown: () => void;
onToggleExpand: () => void;
onSetExternal: (credits: number) => void;
}
function SortableItem({ id, rank, total, name, status, allocated, potential, isExpanded, allocations, onMoveUp, onMoveDown, onToggleExpand }: SortableItemProps) {
function SortableItem({ id, rank, total, name, status, allocated, potential, external, isExpanded, allocations, onMoveUp, onMoveDown, onToggleExpand, onSetExternal }: SortableItemProps) {
const {
attributes,
listeners,
@@ -192,8 +309,11 @@ function SortableItem({ id, rank, total, name, status, allocated, potential, isE
></span>
<span style={{ color: '#999', fontSize: '12px', minWidth: '20px' }}>{rank}.</span>
<span style={{ flex: 1, fontSize: '13px', color: '#333' }}>{name}</span>
<span onClick={(e) => e.stopPropagation()}>
<ExternalCreditChip value={external} onChange={onSetExternal} />
</span>
<span style={{ fontSize: '11px', color: '#888', whiteSpace: 'nowrap' }}>
{allocated > 0 ? allocated.toFixed(1) : '0'} / 9.0
{(allocated + external) > 0 ? (allocated + external).toFixed(1) : '0'} / 9.0
</span>
<span
style={{
@@ -205,13 +325,13 @@ function SortableItem({ id, rank, total, name, status, allocated, potential, isE
{style.label}
</span>
</div>
<CreditBar allocated={allocated} potential={potential} threshold={9} />
<CreditBar allocated={allocated} potential={potential} threshold={9} external={external} />
<div style={{
maxHeight: isAchieved && isExpanded ? '200px' : '0',
overflow: 'hidden',
transition: 'max-height 200ms ease-out',
}}>
<AllocationBreakdown specId={id} allocations={allocations} />
<AllocationBreakdown specId={id} allocations={allocations} external={external} />
</div>
</div>
);
@@ -220,11 +340,13 @@ function SortableItem({ id, rank, total, name, status, allocated, potential, isE
interface SpecializationRankingProps {
ranking: string[];
result: AllocationResult;
externalCredits: Record<string, number>;
headerSlot?: React.ReactNode;
onReorder: (ranking: string[]) => void;
onSetExternalCredit: (specId: string, credits: number) => void;
}
export function SpecializationRanking({ ranking, result, headerSlot, onReorder }: SpecializationRankingProps) {
export function SpecializationRanking({ ranking, result, externalCredits, headerSlot, onReorder, onSetExternalCredit }: SpecializationRankingProps) {
const breakpoint = useMediaQuery();
const isMobile = breakpoint === 'mobile';
@@ -287,12 +409,14 @@ export function SpecializationRanking({ ranking, result, headerSlot, onReorder }
<DesktopSpecStrip
ranking={ranking}
result={result}
externalCredits={externalCredits}
sensors={sensors}
onDragEnd={handleDragEnd}
getAllocatedCredits={getAllocatedCredits}
specMap={specMap}
achievedSummary={achievedSummary}
headerSlot={headerSlot}
onSetExternalCredit={onSetExternalCredit}
/>
);
}
@@ -318,11 +442,13 @@ export function SpecializationRanking({ ranking, result, headerSlot, onReorder }
status={result.statuses[id]}
allocated={getAllocatedCredits(id)}
potential={result.upperBounds[id] || 0}
external={externalCredits[id] ?? 0}
isExpanded={expanded.has(id)}
allocations={result.allocations}
onMoveUp={() => { if (i > 0) onReorder(arrayMove([...ranking], i, i - 1)); }}
onMoveDown={() => { if (i < ranking.length - 1) onReorder(arrayMove([...ranking], i, i + 1)); }}
onToggleExpand={() => toggleExpand(id)}
onSetExternal={(credits) => onSetExternalCredit(id, credits)}
/>
))}
</SortableContext>
@@ -336,15 +462,17 @@ export function SpecializationRanking({ ranking, result, headerSlot, onReorder }
interface DesktopSpecStripProps {
ranking: string[];
result: AllocationResult;
externalCredits: Record<string, number>;
sensors: ReturnType<typeof useSensors>;
onDragEnd: (event: DragEndEvent) => void;
getAllocatedCredits: (specId: string) => number;
specMap: Map<string, { id: string; name: string; abbreviation: string }>;
achievedSummary: string;
headerSlot?: React.ReactNode;
onSetExternalCredit: (specId: string, credits: number) => void;
}
function DesktopSpecStrip({ ranking, result, sensors, onDragEnd, getAllocatedCredits, specMap, achievedSummary, headerSlot }: DesktopSpecStripProps) {
function DesktopSpecStrip({ ranking, result, externalCredits, sensors, onDragEnd, getAllocatedCredits, specMap, achievedSummary, headerSlot, onSetExternalCredit }: DesktopSpecStripProps) {
const [popover, setPopover] = useState<{ specId: string; anchorRect: DOMRect } | null>(null);
const hoverCloseTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -403,6 +531,7 @@ function DesktopSpecStrip({ ranking, result, sensors, onDragEnd, getAllocatedCre
status={result.statuses[id]}
allocated={getAllocatedCredits(id)}
potential={result.upperBounds[id] || 0}
external={externalCredits[id] ?? 0}
isOpen={popover?.specId === id}
onHoverOpen={openPopover}
onHoverLeave={handleHoverLeave}
@@ -410,6 +539,7 @@ function DesktopSpecStrip({ ranking, result, sensors, onDragEnd, getAllocatedCre
if (popover?.specId === specId) closePopover();
else openPopover(specId, rect);
}}
onSetExternal={(credits) => onSetExternalCredit(id, credits)}
/>
))}
</div>
@@ -421,6 +551,7 @@ function DesktopSpecStrip({ ranking, result, sensors, onDragEnd, getAllocatedCre
name={specMap.get(popover.specId)?.name ?? popover.specId}
status={result.statuses[popover.specId]}
allocated={getAllocatedCredits(popover.specId)}
external={externalCredits[popover.specId] ?? 0}
allocations={result.allocations}
anchorRect={popover.anchorRect}
onClose={closePopover}
@@ -440,13 +571,15 @@ interface SpecChipProps {
status: SpecStatus;
allocated: number;
potential: number;
external: number;
isOpen: boolean;
onHoverOpen: (specId: string, rect: DOMRect) => void;
onHoverLeave: () => void;
onTapToggle: (specId: string, rect: DOMRect) => void;
onSetExternal: (credits: number) => void;
}
function SpecChip({ id, rank, name, abbreviation, status, allocated, potential, isOpen, onHoverOpen, onHoverLeave, onTapToggle }: SpecChipProps) {
function SpecChip({ id, rank, name, abbreviation, status, allocated, potential, external, isOpen, onHoverOpen, onHoverLeave, onTapToggle, onSetExternal }: SpecChipProps) {
const {
attributes,
listeners,
@@ -459,10 +592,12 @@ function SpecChip({ id, rank, name, abbreviation, status, allocated, potential,
const style = STATUS_STYLES[status] || STATUS_STYLES.unreachable;
const tagColor = specColor(id);
const threshold = 9;
const denom = Math.max(potential, threshold);
const allocPct = Math.min((allocated / denom) * 100, 100);
const potentialPct = Math.min((potential / denom) * 100, 100);
const denom = Math.max(potential + external, threshold);
const externalPct = Math.min((external / denom) * 100, 100);
const allocatedEnd = Math.min(((external + allocated) / denom) * 100, 100);
const potentialEnd = Math.min(((external + potential) / denom) * 100, 100);
const thresholdPct = Math.min((threshold / denom) * 100, 100);
const isAchievedColor = (allocated + external) >= threshold;
const handleMouseEnter = (e: React.MouseEvent<HTMLDivElement>) => {
if (window.matchMedia('(hover: hover)').matches) {
@@ -531,8 +666,11 @@ function SpecChip({ id, rank, name, abbreviation, status, allocated, potential,
}}>
{abbreviation}
</span>
<span style={{ color: style.color, fontWeight: 700, marginLeft: 'auto' }} aria-label={style.label}>
{statusGlyph}
<span style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: '4px' }}>
<ExternalCreditChip value={external} onChange={onSetExternal} compact />
<span style={{ color: style.color, fontWeight: 700 }} aria-label={style.label}>
{statusGlyph}
</span>
</span>
</div>
<div style={{
@@ -553,17 +691,24 @@ function SpecChip({ id, rank, name, abbreviation, status, allocated, potential,
}}>
{potential > allocated && (
<div style={{
position: 'absolute', left: 0, top: 0, height: '100%',
width: `${potentialPct}%`, background: '#bfdbfe',
transition: 'width 300ms ease-out',
position: 'absolute', left: `${externalPct}%`, top: 0, height: '100%',
width: `${Math.max(0, potentialEnd - externalPct)}%`, background: '#bfdbfe',
transition: 'width 300ms ease-out, left 300ms ease-out',
}} />
)}
<div style={{
position: 'absolute', left: 0, top: 0, height: '100%',
width: `${allocPct}%`,
background: allocated >= threshold ? '#22c55e' : '#3b82f6',
transition: 'width 300ms ease-out',
position: 'absolute', left: `${externalPct}%`, top: 0, height: '100%',
width: `${Math.max(0, allocatedEnd - externalPct)}%`,
background: isAchievedColor ? '#22c55e' : '#3b82f6',
transition: 'width 300ms ease-out, left 300ms ease-out',
}} />
{external > 0 && (
<div style={{
position: 'absolute', left: 0, top: 0, height: '100%',
width: `${externalPct}%`, background: EXTERNAL_COLOR,
transition: 'width 300ms ease-out',
}} />
)}
<div style={{
position: 'absolute', left: `${thresholdPct}%`, top: '-1px',
width: '1px', height: '6px', background: '#666',
@@ -578,6 +723,7 @@ interface SpecChipPopoverProps {
name: string;
status: SpecStatus;
allocated: number;
external: number;
allocations: Record<string, Record<string, number>>;
anchorRect: DOMRect;
onClose: () => void;
@@ -585,7 +731,7 @@ interface SpecChipPopoverProps {
onHoverLeave: () => void;
}
function SpecChipPopover({ specId, name, status, allocated, allocations, anchorRect, onClose, onHoverEnter, onHoverLeave }: SpecChipPopoverProps) {
function SpecChipPopover({ specId, name, status, allocated, external, allocations, anchorRect, onClose, onHoverEnter, onHoverLeave }: SpecChipPopoverProps) {
const popoverRef = useRef<HTMLDivElement>(null);
const style = STATUS_STYLES[status] || STATUS_STYLES.unreachable;
@@ -663,15 +809,21 @@ function SpecChipPopover({ specId, name, status, allocated, allocations, anchorR
{style.label}
</span>
<span style={{ fontSize: '12px', color: '#475569', fontVariantNumeric: 'tabular-nums' }}>
{allocated > 0 ? allocated.toFixed(1) : '0'} / 9.0 credits
{(allocated + external) > 0 ? (allocated + external).toFixed(1) : '0'} / 9.0 credits
</span>
</div>
{contributions.length > 0 && (
{(contributions.length > 0 || external > 0) && (
<div style={{ marginTop: '4px', borderTop: '1px solid #f1f5f9', paddingTop: '6px' }}>
<div style={{ fontSize: '11px', color: '#94a3b8', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.4px', marginBottom: '4px' }}>
Contributing courses
Contributing credits
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '2px' }}>
{external > 0 && (
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '12px', color: EXTERNAL_COLOR, fontStyle: 'italic' }}>
<span style={{ paddingRight: '8px' }}>External</span>
<span style={{ fontWeight: 600, fontVariantNumeric: 'tabular-nums' }}>{external.toFixed(1)}</span>
</div>
)}
{contributions.map((c, i) => (
<div key={i} style={{ display: 'flex', justifyContent: 'space-between', fontSize: '12px', color: '#475569' }}>
<span style={{ paddingRight: '8px' }}>{c.courseName}</span>
@@ -171,3 +171,85 @@ describe('computeUpperBounds', () => {
expect(bounds['FIN']).toBe(2.5);
});
});
describe('checkFeasibility with externalCredits', () => {
it('reduces demand by external credit amount', () => {
// Without external: 3 FIN courses (7.5 credits) is infeasible for 9-credit threshold
const courses = ['spr2-financial-services', 'spr5-corporate-finance', 'sum3-valuation'];
const without = checkFeasibility(courses, ['FIN']);
expect(without.feasible).toBe(false);
// With 2.5 external: 7.5 in-program + 2.5 external = 10.0; demand becomes 6.5, feasible
const withExt = checkFeasibility(courses, ['FIN'], null, { FIN: 2.5 });
expect(withExt.feasible).toBe(true);
});
it('omits LP constraints when external alone meets threshold', () => {
// 9.0 external + 0 in-program courses → feasible with no allocations
const result = checkFeasibility([], ['BNK'], null, { BNK: 9 });
expect(result.feasible).toBe(true);
// No allocations expected since the LP didn't run for BNK
let bnkTotal = 0;
for (const courseAlloc of Object.values(result.allocations)) {
bnkTotal += courseAlloc['BNK'] || 0;
}
expect(bnkTotal).toBe(0);
});
it('preserves prior behavior when externalCredits is empty', () => {
const courses = ['spr2-financial-services', 'spr3-mergers-acquisitions', 'spr5-corporate-finance', 'sum3-valuation'];
const before = checkFeasibility(courses, ['FIN']);
const after = checkFeasibility(courses, ['FIN'], null, {});
expect(before.feasible).toBe(after.feasible);
});
it('returns feasible immediately when all targets are externally met', () => {
const result = checkFeasibility([], ['FIN', 'BNK'], null, { FIN: 9, BNK: 12 });
expect(result.feasible).toBe(true);
});
});
describe('computeUpperBounds with externalCredits', () => {
it('adds external credits to bounds', () => {
const bounds = computeUpperBounds(
['spr1-global-immersion'],
['spr5', 'fall3'],
undefined,
{ GLB: 5 },
);
expect(bounds['GLB']).toBe(7.5 + 5);
});
it('non-listed specs are unaffected', () => {
const bounds = computeUpperBounds(
['spr2-financial-services'],
[],
undefined,
{ GLB: 5 },
);
expect(bounds['FIN']).toBe(2.5);
expect(bounds['GLB']).toBe(5);
});
});
describe('preFilterCandidates with externalCredits', () => {
it('admits a spec whose in-program potential is below threshold but external closes the gap', () => {
// GLB has only 1 set (spr1) selected — 2.5 credits, well below 9
const selected = ['spr1-global-immersion'];
const openSets: string[] = [];
const without = preFilterCandidates(selected, openSets);
expect(without).not.toContain('GLB');
const withExt = preFilterCandidates(selected, openSets, undefined, { GLB: 7 });
// 2.5 + 7 = 9.5 >= 9
expect(withExt).toContain('GLB');
});
it('still excludes specs whose required course is unavailable', () => {
// BRM requires fall4-brand-strategy. Pin fall4 to game-theory (different course).
const selected = ['fall4-game-theory'];
const openSets = ['spr1', 'spr2', 'spr3', 'spr4', 'spr5', 'sum1', 'sum2', 'sum3', 'fall1', 'fall2', 'fall3'];
const candidates = preFilterCandidates(selected, openSets, undefined, { BRM: 100 });
expect(candidates).not.toContain('BRM');
});
});
@@ -163,3 +163,83 @@ describe('optimize (integration)', () => {
expect(prioResult.achieved.length).toBeGreaterThanOrEqual(0);
});
});
describe('externalCredits behavior', () => {
it('hard 3-spec cap holds even with external credits', () => {
// Even with 9 external HCR (a spec the courses don't otherwise support),
// maximizeCount must never report more than 3 achieved.
const result = maximizeCount(
financeHeavyCourses,
allSpecIds,
[],
undefined,
{ HCR: 9 },
);
expect(result.achieved.length).toBeLessThanOrEqual(3);
});
it('external credits can substitute into the 3-spec set', () => {
// External 9 in HCR makes HCR feasible for free; the optimizer can pick
// HCR as one of the 3 achieved specs.
const result = maximizeCount(
financeHeavyCourses,
allSpecIds,
[],
undefined,
{ HCR: 9 },
);
expect(result.achieved).toContain('HCR');
});
it('missing_required precedence: external alone cannot achieve a gated spec', () => {
// BRM requires fall4-brand-strategy. Don't include it; pin fall4-game-theory instead.
const noBrandStrategy = financeHeavyCourses.filter((c) => c !== 'fall4-financial-services')
.concat(['fall4-game-theory']);
const result = maximizeCount(
noBrandStrategy,
allSpecIds,
[],
undefined,
{ BRM: 9 },
);
expect(result.achieved).not.toContain('BRM');
const statuses = determineStatuses(
noBrandStrategy,
[],
result.achieved,
undefined,
{ BRM: 9 },
);
expect(statuses['BRM']).toBe('missing_required');
});
it('priorityOrder honors external credits in achievability', () => {
// Rank HCR first; without external it would be skipped, with 9 external it's first achieved
const ranking = ['HCR', ...allSpecIds.filter((id) => id !== 'HCR')];
const without = priorityOrder(financeHeavyCourses, ranking, []);
expect(without.achieved).not.toContain('HCR');
const withExt = priorityOrder(
financeHeavyCourses,
ranking,
[],
undefined,
{ HCR: 9 },
);
expect(withExt.achieved[0]).toBe('HCR');
});
it('upper bounds reflect external credit additions', () => {
const result = optimize(
[],
allSpecIds,
[],
'maximize-count',
undefined,
{ GLB: 5 },
);
expect(result.upperBounds['GLB']).toBe(5);
});
});
+5 -2
View File
@@ -207,6 +207,7 @@ export function searchDecisionTree(
excludedCourseIds?: Set<string>,
skipKeys?: Set<string>,
pinnedAssignments?: Record<string, string>,
externalCredits?: Record<string, number>,
): SearchResult {
const fn = mode === 'maximize-count' ? maximizeCount : priorityOrder;
const scorer = makePriorityScorer(ranking);
@@ -214,6 +215,7 @@ export function searchDecisionTree(
pinnedCourseIds,
openSetIds,
excludedCourseIds,
externalCredits,
);
const priorityTarget = selectPriorityTarget(ranking, upperBounds);
// Pinned assignments (setId -> courseId) for any pinned sets — included in
@@ -278,7 +280,7 @@ export function searchDecisionTree(
const courses: string[] = [];
for (const setId of openSetIds) courses.push(accumulated[setId]);
const selected = [...pinnedCourseIds, ...courses];
const result = fn(selected, ranking, [], excludedCourseIds);
const result = fn(selected, ranking, [], excludedCourseIds, externalCredits);
const score = scorer(result.achieved);
const outcome: PlanOutcome = {
@@ -387,8 +389,9 @@ export function deriveFromLeaves(
ranking: string[],
openSetIds: string[],
excludedCourseIds?: Set<string>,
externalCredits?: Record<string, number>,
): { topK: PlanOutcome[]; setAnalyses: SetAnalysis[] } {
const upperBounds = computeUpperBounds([], openSetIds, excludedCourseIds);
const upperBounds = computeUpperBounds([], openSetIds, excludedCourseIds, externalCredits);
const priorityTarget = selectPriorityTarget(ranking, upperBounds);
const setAnalyses: Record<string, SetAnalysis> = {};
+25 -6
View File
@@ -18,19 +18,33 @@ export interface FeasibilityResult {
*
* When Strategy is in targetSpecs, s2Choice controls which S2 course (if any)
* may contribute credits to Strategy.
*
* `externalCredits` (specId -> credits) reduces each spec's demand by that
* amount. Specs whose external alone meets the 9-credit threshold contribute
* no constraints or variables to the LP — they are achieved without any
* in-program allocation, so `result.allocations` will not list them.
*/
export function checkFeasibility(
selectedCourseIds: string[],
targetSpecIds: string[],
s2Choice: string | null = null,
externalCredits?: Record<string, number>,
): FeasibilityResult {
if (targetSpecIds.length === 0) {
return { feasible: true, allocations: {} };
}
// Specs whose external credits already meet the threshold drop out of the LP.
const activeTargets = targetSpecIds.filter(
(specId) => CREDIT_THRESHOLD - (externalCredits?.[specId] ?? 0) > 0,
);
if (activeTargets.length === 0) {
return { feasible: true, allocations: {} };
}
// Build the set of valid (course, spec) pairs
const selectedSet = new Set(selectedCourseIds);
const targetSet = new Set(targetSpecIds);
const targetSet = new Set(activeTargets);
// Build LP model
const variables: Record<string, Record<string, number>> = {};
@@ -59,9 +73,10 @@ export function checkFeasibility(
}
}
// For each target spec, add a demand constraint: sum of allocations >= 9
for (const specId of targetSpecIds) {
constraints[`need_${specId}`] = { min: CREDIT_THRESHOLD };
// For each active target spec, add a demand constraint reduced by external credits
for (const specId of activeTargets) {
const adjusted = CREDIT_THRESHOLD - (externalCredits?.[specId] ?? 0);
constraints[`need_${specId}`] = { min: adjusted };
}
// If no variables were created, it's infeasible
@@ -103,11 +118,13 @@ export function checkFeasibility(
/**
* 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.
* External credits boost the per-spec potential before the threshold check.
*/
export function preFilterCandidates(
selectedCourseIds: string[],
openSetIds: string[],
excludedCourseIds?: Set<string>,
externalCredits?: Record<string, number>,
): string[] {
const selectedSet = new Set(selectedCourseIds);
const openSetSet = new Set(openSetIds);
@@ -126,7 +143,7 @@ export function preFilterCandidates(
// Check upper-bound credit potential
const entries = coursesBySpec[spec.id] || [];
let potential = 0;
let potential = externalCredits?.[spec.id] ?? 0;
const countedSets = new Set<string>();
for (const e of entries) {
if (excludedCourseIds?.has(e.courseId)) continue;
@@ -163,11 +180,13 @@ export function enumerateS2Choices(selectedCourseIds: string[]): (string | null)
/**
* Compute upper-bound credit potential per specialization.
* Ignores credit sharing — used only for reachability status determination.
* External credits add directly to each spec's bound.
*/
export function computeUpperBounds(
selectedCourseIds: string[],
openSetIds: string[],
excludedCourseIds?: Set<string>,
externalCredits?: Record<string, number>,
): Record<string, number> {
const selectedSet = new Set(selectedCourseIds);
const openSetSet = new Set(openSetIds);
@@ -175,7 +194,7 @@ export function computeUpperBounds(
for (const spec of SPECIALIZATIONS) {
const entries = coursesBySpec[spec.id] || [];
let potential = 0;
let potential = externalCredits?.[spec.id] ?? 0;
const countedSets = new Set<string>();
for (const e of entries) {
if (excludedCourseIds?.has(e.courseId)) continue;
+32 -19
View File
@@ -34,16 +34,17 @@ function combinations<T>(arr: T[], k: number): T[][] {
function checkWithS2(
selectedCourseIds: string[],
targetSpecIds: string[],
externalCredits?: Record<string, number>,
): { feasible: boolean; allocations: Record<string, Record<string, number>> } {
const hasStrategy = targetSpecIds.includes('STR');
if (!hasStrategy) {
return checkFeasibility(selectedCourseIds, targetSpecIds);
return checkFeasibility(selectedCourseIds, targetSpecIds, null, externalCredits);
}
// Enumerate S2 choices
const s2Choices = enumerateS2Choices(selectedCourseIds);
for (const s2Choice of s2Choices) {
const result = checkFeasibility(selectedCourseIds, targetSpecIds, s2Choice);
const result = checkFeasibility(selectedCourseIds, targetSpecIds, s2Choice, externalCredits);
if (result.feasible) return result;
}
return { feasible: false, allocations: {} };
@@ -58,19 +59,22 @@ export function maximizeCount(
ranking: string[],
openSetIds: string[],
excludedCourseIds?: Set<string>,
externalCredits?: Record<string, number>,
): { achieved: string[]; allocations: Record<string, Record<string, number>> } {
const candidates = preFilterCandidates(selectedCourseIds, openSetIds, excludedCourseIds);
const candidates = preFilterCandidates(selectedCourseIds, openSetIds, excludedCourseIds, externalCredits);
// Only check specs that can be achieved from selected courses alone (not open sets)
// Filter to candidates that have qualifying selected courses
// A spec is achievable if it has any selected qualifying course OR if its
// external credits already meet the threshold (no in-program course needed).
const achievable = candidates.filter((specId) => {
if ((externalCredits?.[specId] ?? 0) >= CREDIT_THRESHOLD) return true;
const entries = coursesBySpec[specId] || [];
return entries.some((e) => selectedCourseIds.includes(e.courseId));
});
const priorityScore = makePriorityScorer(ranking);
// Try from size 3 down to 0
// Hard cap of 3 specializations matches program policy (the school does not
// award more than 3, regardless of credit math).
const maxSize = Math.min(3, achievable.length);
for (let size = maxSize; size >= 1; size--) {
const subsets = combinations(achievable, size);
@@ -82,7 +86,7 @@ export function maximizeCount(
let bestScore = -1;
for (const subset of subsets) {
const result = checkWithS2(selectedCourseIds, subset);
const result = checkWithS2(selectedCourseIds, subset, externalCredits);
if (result.feasible) {
const score = priorityScore(subset);
if (score > bestScore) {
@@ -107,12 +111,15 @@ export function priorityOrder(
ranking: string[],
openSetIds: string[],
excludedCourseIds?: Set<string>,
externalCredits?: Record<string, number>,
): { achieved: string[]; allocations: Record<string, Record<string, number>> } {
const candidates = new Set(preFilterCandidates(selectedCourseIds, openSetIds, excludedCourseIds));
const candidates = new Set(preFilterCandidates(selectedCourseIds, openSetIds, excludedCourseIds, externalCredits));
// Only consider specs that have qualifying selected courses
const withSelectedCourses = new Set(
// A spec is eligible if it has qualifying selected courses OR if external
// credits alone meet the threshold.
const eligible = new Set(
SPECIALIZATIONS.filter((spec) => {
if ((externalCredits?.[spec.id] ?? 0) >= CREDIT_THRESHOLD) return true;
const entries = coursesBySpec[spec.id] || [];
return entries.some((e) => selectedCourseIds.includes(e.courseId));
}).map((s) => s.id),
@@ -123,11 +130,11 @@ export function priorityOrder(
for (const specId of ranking) {
if (!candidates.has(specId)) continue;
if (!withSelectedCourses.has(specId)) continue;
if (!eligible.has(specId)) continue;
if (achieved.length >= 3) break;
const trySet = [...achieved, specId];
const result = checkWithS2(selectedCourseIds, trySet);
const result = checkWithS2(selectedCourseIds, trySet, externalCredits);
if (result.feasible) {
achieved.push(specId);
lastAllocations = result.allocations;
@@ -139,17 +146,22 @@ export function priorityOrder(
/**
* Determine the status of each specialization after optimization.
*
* Required-course gates take precedence over external credits: a spec with an
* unsatisfied `requiredCourseId` stays in `missing_required` regardless of
* how much external credit is recorded.
*/
export function determineStatuses(
selectedCourseIds: string[],
openSetIds: string[],
achieved: string[],
excludedCourseIds?: Set<string>,
externalCredits?: Record<string, number>,
): Record<string, SpecStatus> {
const achievedSet = new Set(achieved);
const selectedSet = new Set(selectedCourseIds);
const openSetSet = new Set(openSetIds);
const upperBounds = computeUpperBounds(selectedCourseIds, openSetIds, excludedCourseIds);
const upperBounds = computeUpperBounds(selectedCourseIds, openSetIds, excludedCourseIds, externalCredits);
const statuses: Record<string, SpecStatus> = {};
for (const spec of SPECIALIZATIONS) {
@@ -158,7 +170,7 @@ export function determineStatuses(
continue;
}
// Check required course gate
// Check required course gate — external credits never override this.
if (spec.requiredCourseId) {
if (!selectedSet.has(spec.requiredCourseId)) {
const requiredCourse = COURSES.find((c) => c.id === spec.requiredCourseId)!;
@@ -169,7 +181,7 @@ export function determineStatuses(
}
}
// Check upper bound
// Check upper bound (external-credit-aware via computeUpperBounds)
if (upperBounds[spec.id] < CREDIT_THRESHOLD) {
statuses[spec.id] = 'unreachable';
continue;
@@ -184,7 +196,7 @@ export function determineStatuses(
const filteredCourseIds = excludedCourseIds
? selectedCourseIds.filter((id) => !excludedCourseIds.has(id))
: selectedCourseIds;
const feasResult = checkWithS2(filteredCourseIds, testSet);
const feasResult = checkWithS2(filteredCourseIds, testSet, externalCredits);
statuses[spec.id] = feasResult.feasible ? 'achievable' : 'unreachable';
} else {
statuses[spec.id] = 'achievable';
@@ -203,11 +215,12 @@ export function optimize(
openSetIds: string[],
mode: 'maximize-count' | 'priority-order',
excludedCourseIds?: Set<string>,
externalCredits?: Record<string, number>,
): AllocationResult {
const fn = mode === 'maximize-count' ? maximizeCount : priorityOrder;
const { achieved, allocations } = fn(selectedCourseIds, ranking, openSetIds, excludedCourseIds);
const statuses = determineStatuses(selectedCourseIds, openSetIds, achieved, excludedCourseIds);
const upperBounds = computeUpperBounds(selectedCourseIds, openSetIds, excludedCourseIds);
const { achieved, allocations } = fn(selectedCourseIds, ranking, openSetIds, excludedCourseIds, externalCredits);
const statuses = determineStatuses(selectedCourseIds, openSetIds, achieved, excludedCourseIds, externalCredits);
const upperBounds = computeUpperBounds(selectedCourseIds, openSetIds, excludedCourseIds, externalCredits);
return { achieved, allocations, statuses, upperBounds };
}
+40 -6
View File
@@ -21,6 +21,7 @@ const LEAF_CACHE_CAP = 500_000;
interface LeafCache {
ranking: string[];
mode: OptimizationMode;
externalCreditsKey: string;
leaves: Map<string, PlanOutcome>;
}
@@ -30,6 +31,7 @@ export interface AppState {
ranking: string[];
mode: OptimizationMode;
pinnedCourses: Record<string, string | null>; // setId -> courseId | null
externalCredits: Record<string, number>; // specId -> credits
}
type AppAction =
@@ -37,6 +39,7 @@ type AppAction =
| { type: 'setMode'; mode: OptimizationMode }
| { type: 'pinCourse'; setId: string; courseId: string }
| { type: 'unpinCourse'; setId: string }
| { type: 'setExternalCredit'; specId: string; credits: number }
| { type: 'clearAll' };
function reducer(state: AppState, action: AppAction): AppState {
@@ -52,6 +55,13 @@ function reducer(state: AppState, action: AppAction): AppState {
delete next[action.setId];
return { ...state, pinnedCourses: next };
}
case 'setExternalCredit': {
const credits = Number.isFinite(action.credits) && action.credits > 0 ? action.credits : 0;
const next = { ...state.externalCredits };
if (credits === 0) delete next[action.specId];
else next[action.specId] = credits;
return { ...state, externalCredits: next };
}
case 'clearAll':
return { ...state, pinnedCourses: {} };
}
@@ -62,9 +72,20 @@ function defaultState(): AppState {
ranking: SPECIALIZATIONS.map((s) => s.id),
mode: 'maximize-count',
pinnedCourses: {},
externalCredits: {},
};
}
function sanitizeExternalCredits(raw: unknown): Record<string, number> {
if (!raw || typeof raw !== 'object') return {};
const out: Record<string, number> = {};
for (const [k, v] of Object.entries(raw as Record<string, unknown>)) {
const n = typeof v === 'number' ? v : Number(v);
if (Number.isFinite(n) && n > 0) out[k] = n;
}
return out;
}
function loadState(): AppState {
try {
const raw = localStorage.getItem(STORAGE_KEY);
@@ -76,12 +97,18 @@ function loadState(): AppState {
ranking: parsed.ranking,
mode: parsed.mode,
pinnedCourses: parsed.pinnedCourses ?? {},
externalCredits: sanitizeExternalCredits(parsed.externalCredits),
};
} catch {
return defaultState();
}
}
function externalCreditsKey(ext: Record<string, number>): string {
const keys = Object.keys(ext).filter((k) => ext[k] > 0).sort();
return keys.map((k) => `${k}:${ext[k]}`).join('|');
}
export function useAppState() {
const [state, dispatch] = useReducer(reducer, null, loadState);
const [treeResults, setTreeResults] = useState<SetAnalysis[]>([]);
@@ -94,6 +121,7 @@ export function useAppState() {
const leafCacheRef = useRef<LeafCache>({
ranking: state.ranking,
mode: state.mode,
externalCreditsKey: externalCreditsKey(state.externalCredits),
leaves: new Map(),
});
@@ -137,8 +165,8 @@ export function useAppState() {
// Main-thread optimization (instant)
const optimizationResult: AllocationResult = useMemo(
() => optimize(selectedCourseIds, state.ranking, openSetIds, state.mode, excludedCourseIds),
[selectedCourseIds, state.ranking, openSetIds, state.mode, excludedCourseIds],
() => optimize(selectedCourseIds, state.ranking, openSetIds, state.mode, excludedCourseIds, state.externalCredits),
[selectedCourseIds, state.ranking, openSetIds, state.mode, excludedCourseIds, state.externalCredits],
);
// Pinned assignments map (setId -> courseId) for the cache + worker
@@ -163,19 +191,21 @@ export function useAppState() {
return;
}
// Invalidate cache if ranking or mode has changed
// Invalidate cache if ranking, mode, or external credits have changed
const cache = leafCacheRef.current;
const sameRanking =
cache.ranking.length === state.ranking.length &&
cache.ranking.every((r, i) => r === state.ranking[i]);
if (!sameRanking || cache.mode !== state.mode) {
const externalKey = externalCreditsKey(state.externalCredits);
if (!sameRanking || cache.mode !== state.mode || cache.externalCreditsKey !== externalKey) {
cache.ranking = state.ranking;
cache.mode = state.mode;
cache.externalCreditsKey = externalKey;
cache.leaves.clear();
}
// Compute the orderedCourses per set + expectedTotal (mirrors searchDecisionTree)
const upperBounds = computeUpperBounds(selectedCourseIds, openSetIds, excludedCourseIds);
const upperBounds = computeUpperBounds(selectedCourseIds, openSetIds, excludedCourseIds, state.externalCredits);
const priorityTarget = selectPriorityTarget(state.ranking, upperBounds);
const orderedCoursesPerSet: Record<string, ReturnType<typeof reorderForTarget>> = {};
let expectedTotal = 1;
@@ -218,6 +248,7 @@ export function useAppState() {
state.ranking,
openSetIds,
excludedCourseIds,
state.externalCredits,
);
setTreeResults(cachedAnalyses);
setTopPlans(cachedTopK);
@@ -285,6 +316,7 @@ export function useAppState() {
excludedCourseIds: [...excludedCourseIds],
topK: 10,
skipKeys: filtered.map((l) => assignmentKey(l.courseAssignments)),
externalCredits: state.externalCredits,
};
worker.postMessage(request);
} catch {
@@ -299,12 +331,13 @@ export function useAppState() {
workerRef.current = null;
}
};
}, [selectedCourseIds, openSetIds, state.ranking, state.mode, excludedCourseIds, pinnedAssignments]);
}, [selectedCourseIds, openSetIds, state.ranking, state.mode, excludedCourseIds, pinnedAssignments, state.externalCredits]);
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 }), []);
const setExternalCredit = useCallback((specId: string, credits: number) => dispatch({ type: 'setExternalCredit', specId, credits }), []);
const clearAll = useCallback(() => dispatch({ type: 'clearAll' }), []);
const adoptPlan = useCallback((assignments: Record<string, string>) => {
@@ -329,6 +362,7 @@ export function useAppState() {
setMode,
pinCourse,
unpinCourse,
setExternalCredit,
clearAll,
adoptPlan,
};
+3
View File
@@ -12,6 +12,7 @@ export interface WorkerRequest {
topK?: number;
saturationLimit?: number;
skipKeys?: string[];
externalCredits?: Record<string, number>;
}
export type WorkerResponse =
@@ -38,6 +39,7 @@ self.onmessage = (e: MessageEvent<WorkerRequest>) => {
topK = 10,
skipKeys,
pinnedAssignments,
externalCredits,
} = e.data;
const excludedSet =
@@ -74,6 +76,7 @@ self.onmessage = (e: MessageEvent<WorkerRequest>) => {
excludedSet,
skipSet,
pinnedAssignments,
externalCredits,
);
const final: WorkerResponse = {
+2 -2
View File
@@ -6,8 +6,8 @@ import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
define: {
__APP_VERSION__: JSON.stringify('1.4.0'),
__APP_VERSION_DATE__: JSON.stringify('2026-05-09'),
__APP_VERSION__: JSON.stringify('1.5.0'),
__APP_VERSION_DATE__: JSON.stringify('2026-05-10'),
},
server: {
allowedHosts: ['soos'],