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:
@@ -1,5 +1,16 @@
|
||||
# Changelog
|
||||
|
||||
## v1.5.0 — 2026-05-10
|
||||
|
||||
### Changes
|
||||
|
||||
- **External credits per specialization** — students can now record credits earned in courses taken outside the J27 program. An inline editable amber chip on each specialization card commits a non-negative number; the value is persisted to localStorage alongside the rest of the app state. Credits flow through LP feasibility (per-spec demand reduces by the external amount), upper-bound and pre-filter math (potential rises by the external amount), and decision-tree search (worker request carries the value, search uses external-credit-aware feasibility on every leaf).
|
||||
- **Credit bar gains an amber segment** — the bar's existing in-program allocated/potential stripes shift right to make room for an amber `#f59e0b` external segment at the leftmost edge. The 9-credit threshold tick repositions automatically when external credits push the bar's max width past 9. The "achieved" green color now switches when `allocated + external ≥ 9` (was `allocated ≥ 9`).
|
||||
- **Allocation breakdown shows External line** — when external credits exist, the per-spec allocation breakdown (mobile expand and desktop popover) prepends an italic amber `External — N.N` line above the in-program contributions.
|
||||
- **3-spec cap retained** — the program policy of 3 specializations is unchanged. External credits may shift which 3 specs the optimizer picks (e.g., admitting a spec the in-program courses don't naturally support, or freeing in-program credits for a different combination), but the achieved count never exceeds 3.
|
||||
- **Required-course gates remain authoritative** — external credits never satisfy a `requiredCourseId` gate. A specialization with 9 external credits but a missing required course stays in `missing_required`; the gold bar segment is informational only.
|
||||
- **Leaf-cache invalidation extended** — any change to `externalCredits` clears the leaf cache (treated identically to a `ranking` or `mode` change). No-op edits (committing the same value) leave the cache intact.
|
||||
|
||||
## v1.4.0 — 2026-05-09
|
||||
|
||||
### Changes
|
||||
|
||||
+7
-2
@@ -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 />}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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> = {};
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
@@ -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'],
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-05-10
|
||||
@@ -0,0 +1,145 @@
|
||||
## Context
|
||||
|
||||
The solver treats every credit as in-program: 12 elective slots × 2.5 credits = a 30-credit budget allocated by an LP across specializations whose demand is `≥ 9`. Real students sometimes earn additional credits in cross-registered or transfer courses that the registrar accepts toward a J27 specialization. The tool currently has no representation for those credits, so the "achieved", "achievable", and "unreachable" verdicts understate the student's true position.
|
||||
|
||||
The user has specified the v1 shape:
|
||||
- A simple per-spec number (no labels, no qualifications, no marker types).
|
||||
- Inline editable chip on each spec card.
|
||||
- An amber bar segment in the existing credit bar.
|
||||
|
||||
This document settles the LP integration, the bar layout, the status semantics around required-course gates, and the cache-invalidation hookup.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Per-spec external credits as a first-class input that flows through feasibility, upper-bound, status, and visualization
|
||||
- Zero new abstractions in the data layer — `Course`/`Specialization`/`AllocationResult` shapes unchanged
|
||||
- LP changes are local and small (demand-side adjustment, no new variables)
|
||||
- Bar visualization extends naturally — one new amber stripe stacked left
|
||||
- Inputs persist with the rest of `AppState`
|
||||
|
||||
**Non-Goals:**
|
||||
- Per-entry labels, descriptions, or institution metadata
|
||||
- Multi-spec qualification (a single external entry that splits across specs)
|
||||
- Marker types (S1/S2) for external credits
|
||||
- Letting external credits satisfy required-course gates (BRM/EMT/ENT/SBI)
|
||||
- Per-spec or global caps on external credits
|
||||
- Sharing/import-export of external credit values
|
||||
|
||||
## Decisions
|
||||
|
||||
### Data shape: `Record<string, number>` keyed by `specId`
|
||||
|
||||
```ts
|
||||
externalCredits: Record<string, number>; // specId → credits, default 0
|
||||
```
|
||||
|
||||
Lives on `AppState` next to `ranking`, `mode`, `pinnedCourses`. Persisted to localStorage. Missing keys treated as 0.
|
||||
|
||||
**Alternative considered:** `ExternalCredit[]` with `{id, specId, credits, label}`. Rejected per the user's "simple value" call — labels and entries add UI complexity (list management, IDs, deletion) for no LP benefit. If multi-source attribution becomes necessary later, the shape can grow to an array without touching the LP integration.
|
||||
|
||||
### LP integration: demand reduction, no new variables
|
||||
|
||||
In `feasibility.checkFeasibility`, the per-spec demand becomes `≥ max(0, 9 − external[spec])`:
|
||||
|
||||
```ts
|
||||
for (const specId of targetSpecIds) {
|
||||
const adjusted = Math.max(0, CREDIT_THRESHOLD - (externalCredits[specId] ?? 0));
|
||||
if (adjusted === 0) continue; // already met externally — drop from LP
|
||||
constraints[`need_${specId}`] = { min: adjusted };
|
||||
}
|
||||
```
|
||||
|
||||
Specs whose external alone meets or exceeds 9 contribute no constraint and no variables — the LP doesn't need to allocate any in-program credit to them. The optimizer still treats them as part of the achieved set.
|
||||
|
||||
**Alternative considered:** Inject synthetic `x_external_<spec>` variables with capacity equal to the external value, contributing to the same `need_<spec>` row. Rejected — adds variables for zero benefit; subtraction-from-demand is mathematically equivalent and simpler.
|
||||
|
||||
### Achievement ceiling: hard 3-spec cap retained
|
||||
|
||||
`maximizeCount` (`optimizer.ts:74`) caps subset size at `Math.min(3, achievable.length)`. The cap is **program policy** — the school does not award more than 3 specializations to a student, regardless of credit math. External credits do not change this. They may shift which 3 specs are achievable (e.g., enabling a spec that has no in-program qualifying courses) or free in-program credits to enable a different combination, but the maximum count returned is always ≤3. `priorityOrder` keeps the matching `if (achieved.length >= 3) break;` guard for the same reason.
|
||||
|
||||
**Alternative considered:** Lift the cap when external credits make a 4-spec subset LP-feasible. Rejected — the cap reflects a categorical school rule, not a credit-budget consequence. Reporting `achieved.length === 4` would be misleading regardless of LP feasibility.
|
||||
|
||||
### Status semantics: required-course gates beat external credits
|
||||
|
||||
A spec with `requiredCourseId` (BRM/EMT/ENT/SBI) stays in `missing_required` whenever the required course is not selected and not in an open set, regardless of external credit total. The bar still shows the amber segment (truthful: the student does have those credits) but the status badge doesn't lie.
|
||||
|
||||
**Alternative considered:** Promote to `achievable` if external credits cover the gap. Rejected — the required-course rule is a *categorical* gate, not a credit-count check. The registrar will not award the specialization without the required in-program course.
|
||||
|
||||
### "Achieved" coloring switches at `allocated + external ≥ 9`
|
||||
|
||||
In `CreditBar` at `SpecializationRanking.tsx:59`, the green-vs-blue switch becomes:
|
||||
|
||||
```ts
|
||||
background: (allocated + external) >= threshold ? '#22c55e' : '#3b82f6'
|
||||
```
|
||||
|
||||
The user-visible signal "this spec is met" should reflect total credit, not just in-program credit.
|
||||
|
||||
### Bar layout: amber stripe leftmost, then existing stack
|
||||
|
||||
```
|
||||
0 9 max
|
||||
├──────┬─────────────┬──────────────────┬────────────┤ │
|
||||
│ ext │ allocated │ potential │ unfilled │ │
|
||||
│amber │ green/blue │ light blue │ gray │ │
|
||||
└──────┴─────────────┴──────────────────┴────────────┘ │
|
||||
▲
|
||||
threshold tick
|
||||
```
|
||||
|
||||
`maxWidth` becomes `Math.max(potential + external, threshold)` so the threshold tick stays correctly positioned even when external alone exceeds 9.
|
||||
|
||||
External width: `(external / maxWidth) * 100`, rendered first.
|
||||
Allocated stripe: starts at `external/maxWidth`, ends at `(external + allocated)/maxWidth`.
|
||||
Potential stripe: starts at `(external + allocated)/maxWidth`, ends at `(external + potential)/maxWidth`.
|
||||
|
||||
Color: `#f59e0b` (amber-500). Distinct from the existing green/blue/light-blue palette and warm-vs-cool reads as a different category.
|
||||
|
||||
**Alternative considered:** Mix external credits into the existing allocated stripe with no visual distinction. Rejected — the user explicitly asked for a different color. Visibility of the external contribution is the whole point.
|
||||
|
||||
### Input UI: inline editable chip on the spec card
|
||||
|
||||
Rendered in the spec card, blank (or `+0`) when the value is 0, showing the value (e.g., `+2.5`) when non-zero. Click switches to a numeric input; blur or Enter commits. Validation: parse as float, clamp to `≥ 0`, treat NaN as 0.
|
||||
|
||||
The chip lives next to the spec name, not on the bar itself, so it does not compete with the bar's visual weight.
|
||||
|
||||
**Alternative considered:** Side-panel disclosure listing all 14 specs. Rejected — separates input from feedback; the bar updates on the spec card and the input should live there too.
|
||||
|
||||
### Cache invalidation: external credits join `ranking` and `mode`
|
||||
|
||||
In `useAppState`, the leaf-cache invalidation check (`appState.ts:168-175`) currently compares `ranking` and `mode`. Extend to include a stable signature of `externalCredits` (e.g., a sorted JSON of non-zero entries). Any change to external credits clears the leaf cache, since per-leaf `PlanOutcome` (which encodes achievement and priorityScore) depends on it.
|
||||
|
||||
**Alternative considered:** Re-derive `PlanOutcome` from cached leaves on external-credit change without re-running the worker. Rejected for v1 — `PlanOutcome.achievedSpecs` is computed inside the LP, and external credits change which subsets are feasible. Simpler to invalidate.
|
||||
|
||||
### Worker contract additions
|
||||
|
||||
`WorkerRequest` gains `externalCredits: Record<string, number>`. The worker passes this through to `searchDecisionTree`, which threads it into `optimize`/`checkFeasibility`/`computeUpperBounds` calls. No marker or qualification semantics — it's a flat per-spec number.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **Trust gap between solver and registrar** → Mitigation: the entered numbers are user-asserted; the tool is a planner. A subtle helper line near the input ("Verify with your advisor") is enough; no need for harder gating.
|
||||
- **Misleading achievement when required course is missing** → Mitigation: status badge stays `missing_required`; achievement count for `maximizeCount` excludes specs in that status. The bar's amber segment is informational only.
|
||||
- **3-spec cap retained: external credits never report more than 3 achieved** → Accepted. Matches school policy. UI affordance (the chip + amber bar) still surfaces credit toward a 4th spec, so a student is informed about their position even though the cap holds.
|
||||
- **Cache invalidation churn if user types in the chip rapidly** → Mitigation: chip commits on blur/Enter, not on every keystroke; cache invalidation runs at most once per commit.
|
||||
- **Allocation breakdown listing "External" with no further attribution** → Accepted. The user explicitly chose the labels-free model. If attribution is needed later, the data shape grows.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
Single-PR change. No data migration. localStorage gracefully tolerates missing keys (treat as `{}`).
|
||||
|
||||
1. `feasibility.ts`: add `externalCredits?: Record<string, number>` parameter to `checkFeasibility`, `computeUpperBounds`, `preFilterCandidates`. Subtract from demand; add to bounds.
|
||||
2. `optimizer.ts`: thread the parameter through `maximizeCount`, `priorityOrder`, `determineStatuses`, `optimize`. Keep the `Math.min(3, …)` cap and `priorityOrder`'s `>= 3` guard. Verify `missing_required` status still wins over external coverage.
|
||||
3. `decisionTree.ts` + worker: accept `externalCredits` in the search input and `WorkerRequest`; thread through.
|
||||
4. `appState.ts`: add `externalCredits` to `AppState`, reducer (`setExternalCredit { specId, credits }`), localStorage load/save, and leaf-cache signature.
|
||||
5. `SpecializationRanking.tsx`: extend `CreditBar` with an `external` prop and the amber stripe; extend `AllocationBreakdown` with the External line; add inline editable chip on the spec card.
|
||||
6. Tests: feasibility demand reduction (full and partial), upper-bound boost, lifted ceiling, missing_required precedence, cache invalidation on external change.
|
||||
7. Browser verify: enter external credits on a spec, watch bar update; verify achievement crosses 9 with combined credit; verify required-course gate still blocks BRM/EMT/ENT/SBI.
|
||||
8. Version bump in `vite.config.ts`; CHANGELOG entry; ship.
|
||||
|
||||
Rollback: revert. localStorage `externalCredits` key remains in stored state but is harmlessly ignored by the prior code path.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Default position of the inline chip on the spec card — left of name, right edge, under the bar. Resolve during implementation; pick whatever fits the existing card layout without disrupting drag handle / status badge alignment.
|
||||
- Whether to show a small helper text ("Verify with your advisor") in the panel or as a tooltip on the chip. Defer to design polish at the UI step.
|
||||
@@ -0,0 +1,39 @@
|
||||
## Why
|
||||
|
||||
Students sometimes earn credits in courses taken outside the J27 program (cross-registration, transfer, etc.) that the registrar will count toward a specialization. The current solver has no way to represent these credits, so its achievement and reachability claims understate what the student actually has. We want a lightweight escape hatch that lets a student dial in external credits per specialization and have the optimizer, status determination, and credit-bar visualization reflect them.
|
||||
|
||||
## What Changes
|
||||
|
||||
- New `externalCredits: Record<string, number>` field on `AppState` (specId → credits, default 0). Persisted to localStorage with the rest of the state. Edited via per-spec inline chip on the spec card.
|
||||
- LP feasibility (`checkFeasibility`) reduces each spec's demand from `≥ 9` to `≥ max(0, 9 − external[spec])`. Specs whose external credits already meet the threshold drop out of the LP entirely.
|
||||
- Upper-bound and pre-filter math (`computeUpperBounds`, `preFilterCandidates`) add `external[spec]` to each spec's potential.
|
||||
- The hard 3-spec cap in `maximizeCount` and `priorityOrder` is **retained** (program policy, not a math consequence). External credits may free in-program credits or substitute for an in-program spec within that cap, but never raise the cap above 3.
|
||||
- "Achieved" coloring switches when `allocated + external ≥ 9` (was `allocated ≥ threshold`).
|
||||
- Credit bar gets a new amber `#f59e0b` segment that fills from the left, before the existing in-program allocated/potential stripes.
|
||||
- Allocation breakdown gains an `External` line item when `external[spec] > 0`.
|
||||
- Required-course gates (BRM/EMT/ENT/SBI) are unchanged — external credits never satisfy them. A spec with sufficient external credits but a missing required course stays in `missing_required`.
|
||||
- Leaf-cache invalidation in `useAppState` extends to `externalCredits` (treated like `ranking`/`mode`).
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
_None._
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `optimization-engine`: external-credit-aware demand, upper-bound, candidate pre-filter, achievement ceiling, and cache invalidation. The LP shape itself (variables, capacity constraints, S2 enumeration) is unchanged.
|
||||
- `unified-specialization-panel`: per-spec inline editable external-credits chip, amber bar segment, "achieved" coloring keyed off combined credit, External line in allocation breakdown.
|
||||
|
||||
## Impact
|
||||
|
||||
- `app/src/data/types.ts` — extend `AppState` shape (via `state/appState.ts`); no change to `Course`/`Specialization`/`AllocationResult`.
|
||||
- `app/src/state/appState.ts` — add `externalCredits` to state, reducer actions (`setExternalCredits` or similar), localStorage load/save, and leaf-cache invalidation signature. Thread the value through to `optimize` and the worker.
|
||||
- `app/src/solver/feasibility.ts` — new optional `externalCredits` parameter to `checkFeasibility`, `computeUpperBounds`, and `preFilterCandidates`; reduce demand and add to bounds.
|
||||
- `app/src/solver/optimizer.ts` — thread `externalCredits` through `maximizeCount`, `priorityOrder`, `determineStatuses`, and `optimize`. The hardcoded 3-spec cap stays. Status determination keeps `missing_required` when applicable regardless of external totals.
|
||||
- `app/src/solver/decisionTree.ts` + `app/src/workers/decisionTree.worker.ts` — accept `externalCredits` in the worker request and propagate to feasibility/upper-bound calls during search.
|
||||
- `app/src/components/SpecializationRanking.tsx` — `CreditBar` accepts `external` and renders the amber segment as the leftmost stripe; `AllocationBreakdown` shows an `External` line; spec card adds an inline editable credits chip.
|
||||
- `app/src/solver/__tests__/` — add LP tests for the demand reduction, upper-bound boost, and the lifted ceiling. Add tests verifying `missing_required` survives external-only achievement.
|
||||
- `app/vite.config.ts` — version bump.
|
||||
- `CHANGELOG.md` — release entry.
|
||||
- No data-file (`data/courses.ts`, `data/specializations.ts`) changes.
|
||||
@@ -0,0 +1,91 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: External credits as per-spec input
|
||||
The application SHALL accept a per-specialization external credit value, expressed as a non-negative number. External credits represent credits earned in courses taken outside the J27 program that the student asserts toward a specialization. The values SHALL be stored in `AppState.externalCredits` as `Record<string, number>` keyed by specialization id. Missing keys SHALL be treated as `0`. The values SHALL be persisted to localStorage alongside the rest of `AppState`.
|
||||
|
||||
#### Scenario: External credits default to zero
|
||||
- **WHEN** a specialization has no entry in `externalCredits`
|
||||
- **THEN** the application SHALL treat its external credits as `0` everywhere (LP demand, upper bounds, bar visualization)
|
||||
|
||||
#### Scenario: External credits persist across reload
|
||||
- **WHEN** the user enters an external credit value and reloads the page
|
||||
- **THEN** the value SHALL be restored from localStorage and applied to the LP and the bar
|
||||
|
||||
#### Scenario: Negative or non-numeric input is rejected
|
||||
- **WHEN** the user attempts to commit a negative number, NaN, or empty string as an external credit value
|
||||
- **THEN** the value SHALL clamp to `0` (treated as no entry)
|
||||
|
||||
### Requirement: External credits reduce LP demand
|
||||
The LP feasibility checker SHALL reduce each specialization's demand from `≥ 9` to `≥ max(0, 9 − external[spec])`. Specializations whose external credits already meet or exceed the 9-credit threshold SHALL be omitted from the LP entirely (no `need_<spec>` constraint, no `x_<course>_<spec>` variables for that spec) while still being counted as achievable in the optimizer's output.
|
||||
|
||||
#### Scenario: Partial external coverage reduces required in-program credits
|
||||
- **WHEN** a specialization has 4.0 external credits and 5.0 in-program credits available from the student's selections
|
||||
- **THEN** the LP SHALL find the spec feasible with `need_<spec>` set to `≥ 5`
|
||||
|
||||
#### Scenario: External alone meets threshold
|
||||
- **WHEN** a specialization has 9.0 or more external credits
|
||||
- **THEN** the LP SHALL omit that spec's demand constraint and any related variables, and the optimizer SHALL include the spec in the achieved set without consuming any in-program credits
|
||||
|
||||
#### Scenario: No external credits preserves prior behavior
|
||||
- **WHEN** every specialization's external credit value is `0`
|
||||
- **THEN** the LP SHALL produce the same constraints, variables, and feasibility verdict as before the change
|
||||
|
||||
### Requirement: External credits raise upper-bound and pre-filter potentials
|
||||
`computeUpperBounds` and `preFilterCandidates` SHALL add `external[spec]` to each specialization's potential credit total. A specialization SHALL pass the pre-filter if `(in-program potential + external) ≥ 9`.
|
||||
|
||||
#### Scenario: External credits unlock previously-unreachable spec
|
||||
- **WHEN** a specialization's in-program potential is 6.0 (below the 9-credit threshold) but the student has 5.0 external credits in it
|
||||
- **THEN** the spec SHALL pass `preFilterCandidates` and SHALL receive an upper bound of `11.0`
|
||||
|
||||
#### Scenario: External credits do not exceed reasonable bounds
|
||||
- **WHEN** external credits are added on top of the in-program upper bound
|
||||
- **THEN** the resulting upper bound SHALL be the simple sum (no cap), reflecting the truthful credit total
|
||||
|
||||
### Requirement: Required-course gates remain authoritative
|
||||
A specialization with a `requiredCourseId` SHALL retain `missing_required` status whenever the required course is neither selected nor available in an open elective set, regardless of the external credit total. External credits SHALL NOT advance the status of such a specialization to `achieved` or `achievable`.
|
||||
|
||||
#### Scenario: External credits cannot satisfy a required course gate
|
||||
- **WHEN** the BRM specialization has 9.0 external credits but Brand Strategy is not selected and is in a pinned set holding a different course
|
||||
- **THEN** BRM's status SHALL remain `missing_required`
|
||||
- **AND** BRM SHALL NOT be counted in the achieved set
|
||||
|
||||
#### Scenario: Required course gate becomes satisfiable
|
||||
- **WHEN** the required course is in an open elective set
|
||||
- **THEN** the spec MAY transition to `achievable` once the LP-with-external-credits confirms feasibility, following the same rules as without external credits
|
||||
|
||||
### Requirement: 3-spec achievement cap is policy, not just budget
|
||||
`maximizeCount` and `priorityOrder` SHALL cap the achieved set at 3 specializations regardless of external credit totals. External credits MAY shift which 3 specs are selected (e.g., admitting a spec that has no in-program qualifying courses, or freeing in-program credits for a different combination), but SHALL NOT raise the count above 3.
|
||||
|
||||
#### Scenario: Hard cap holds without external credits
|
||||
- **WHEN** all `external[spec]` values are `0`
|
||||
- **THEN** `maximizeCount` SHALL never return a subset larger than 3
|
||||
|
||||
#### Scenario: Hard cap holds with sufficient external credits
|
||||
- **WHEN** the student has 9 or more external credits in a spec that the in-program courses do not naturally support
|
||||
- **THEN** the optimizer MAY include that spec in the achieved set in place of one it would otherwise pick, but `maximizeCount` SHALL never return a subset larger than 3
|
||||
|
||||
#### Scenario: priorityOrder respects the cap
|
||||
- **WHEN** the student has external credits sufficient to make 4 or more specs feasible
|
||||
- **THEN** `priorityOrder` SHALL stop adding specs to the achieved set after the third
|
||||
|
||||
### Requirement: Leaf cache invalidates on external-credit change
|
||||
The leaf cache in `useAppState` SHALL be cleared when any value in `externalCredits` changes (treated identically to a `ranking` or `mode` change). The cache invalidation signature SHALL include a deterministic stringification of `externalCredits` (e.g., sorted JSON of non-zero entries).
|
||||
|
||||
#### Scenario: Editing an external credit value clears the cache
|
||||
- **WHEN** the user changes the external credit value for any specialization
|
||||
- **THEN** the leaf cache SHALL be emptied and the next search SHALL run as a full recomputation
|
||||
|
||||
#### Scenario: No-op edit does not clear cache
|
||||
- **WHEN** the user opens the chip input and commits the same value that was already there
|
||||
- **THEN** the cache SHALL be retained (the signature is unchanged)
|
||||
|
||||
### Requirement: External credits propagate through the worker contract
|
||||
The `WorkerRequest` SHALL include `externalCredits: Record<string, number>`. The decision-tree worker SHALL pass this value through to `searchDecisionTree`, which SHALL thread it into all `optimize`, `checkFeasibility`, `computeUpperBounds`, and `preFilterCandidates` calls used during the search.
|
||||
|
||||
#### Scenario: Worker uses external credits during exhaustive search
|
||||
- **WHEN** the worker performs an exhaustive search with non-zero external credits
|
||||
- **THEN** every leaf's `PlanOutcome.achievedSpecs` SHALL reflect the external-credit-aware feasibility verdict
|
||||
|
||||
#### Scenario: Empty external credits behaves like prior worker
|
||||
- **WHEN** the worker receives `externalCredits: {}` (or all-zero values)
|
||||
- **THEN** the worker's behavior and outputs SHALL match the prior implementation
|
||||
@@ -0,0 +1,69 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Inline editable external-credits chip on each spec card
|
||||
Each specialization in the ranking panel SHALL include an inline editable chip for entering an external credit value. The chip SHALL display blank or `+0` when the spec's `externalCredits` value is `0`, and SHALL display `+<value>` (e.g., `+2.5`) when non-zero. Clicking or tapping the chip SHALL switch it to a numeric input field; pressing Enter or blurring the input SHALL commit the value. The chip SHALL be present on both the desktop chip layout and the mobile row layout, sized so it does not displace existing affordances (drag handle, status badge, credit bar).
|
||||
|
||||
#### Scenario: Chip shows the current value
|
||||
- **WHEN** a specialization has `externalCredits[specId] === 2.5`
|
||||
- **THEN** the chip SHALL display `+2.5`
|
||||
|
||||
#### Scenario: Chip is blank when value is zero
|
||||
- **WHEN** a specialization has `externalCredits[specId] === 0` (or no entry)
|
||||
- **THEN** the chip SHALL render in its blank/placeholder state (e.g., `+0` or an unobtrusive add-icon)
|
||||
|
||||
#### Scenario: Click activates input
|
||||
- **WHEN** the user clicks or taps the chip
|
||||
- **THEN** the chip SHALL switch to a numeric input pre-filled with the current value
|
||||
|
||||
#### Scenario: Enter commits the value
|
||||
- **WHEN** the user types a valid non-negative number into the input and presses Enter
|
||||
- **THEN** the value SHALL be saved to `externalCredits[specId]` and the chip SHALL return to display mode showing the new value
|
||||
|
||||
#### Scenario: Blur commits the value
|
||||
- **WHEN** the user types a valid non-negative number into the input and clicks elsewhere
|
||||
- **THEN** the value SHALL be saved and the chip SHALL return to display mode
|
||||
|
||||
#### Scenario: Invalid input clamps to zero
|
||||
- **WHEN** the user commits an empty string, NaN, or a negative number
|
||||
- **THEN** the value SHALL be saved as `0` and the chip SHALL return to its blank state
|
||||
|
||||
### Requirement: Credit bar renders external segment
|
||||
`CreditBar` SHALL accept an `external` prop (a non-negative number). When `external > 0`, the bar SHALL render an amber stripe (`#f59e0b`) at the leftmost edge whose width is proportional to `external / maxWidth`. The existing in-program allocated stripe SHALL stack on top, starting at `external / maxWidth` and ending at `(external + allocated) / maxWidth`. The potential stripe SHALL stack next, starting at `(external + allocated) / maxWidth` and ending at `(external + potential) / maxWidth`. `maxWidth` SHALL be `Math.max(potential + external, threshold)` so that the threshold tick remains correctly positioned.
|
||||
|
||||
#### Scenario: External segment renders for non-zero value
|
||||
- **WHEN** a spec has `external = 2.5`, `allocated = 5.0`, `potential = 7.5`, `threshold = 9`
|
||||
- **THEN** the bar SHALL render an amber segment from `0%` to `2.5/10 = 25%` of the bar width, an allocated segment from `25%` to `75%`, and a potential segment from `75%` to `100%` (where `maxWidth = 10`)
|
||||
|
||||
#### Scenario: No external segment when value is zero
|
||||
- **WHEN** a spec has `external = 0`
|
||||
- **THEN** the bar SHALL render with the same layout as before this change (no amber segment)
|
||||
|
||||
#### Scenario: External alone exceeds threshold
|
||||
- **WHEN** a spec has `external = 10` and `threshold = 9`
|
||||
- **THEN** the threshold tick SHALL still appear at the `9 / maxWidth` position, with the amber segment crossing it
|
||||
|
||||
### Requirement: Achievement coloring keys off combined credit
|
||||
The credit bar's "achieved" green color (`#22c55e`) SHALL switch on when `allocated + external ≥ threshold`. Otherwise the in-program allocated stripe SHALL render in the existing in-progress blue (`#3b82f6`).
|
||||
|
||||
#### Scenario: Combined credit reaches threshold via external
|
||||
- **WHEN** `allocated = 7.0` and `external = 2.5`
|
||||
- **THEN** the in-program allocated stripe SHALL render in green
|
||||
|
||||
#### Scenario: Combined credit below threshold
|
||||
- **WHEN** `allocated = 4.0` and `external = 2.5`
|
||||
- **THEN** the in-program allocated stripe SHALL render in blue
|
||||
|
||||
### Requirement: Allocation breakdown shows External line
|
||||
`AllocationBreakdown` SHALL prepend an `External` line when `externalCredits[specId] > 0`, displaying the credit value (e.g., `External — 2.5`). The line SHALL be visually distinguishable from in-program contributions (e.g., italic label, amber accent, or other lightweight treatment) so the reader can identify it without explanation.
|
||||
|
||||
#### Scenario: External line appears for non-zero value
|
||||
- **WHEN** a specialization has `external = 2.5` and one in-program contribution of `2.0` from "Real Estate Finance"
|
||||
- **THEN** the breakdown SHALL list `External — 2.5` followed by `Real Estate Finance — 2.0`
|
||||
|
||||
#### Scenario: External line absent for zero value
|
||||
- **WHEN** a specialization has `external = 0`
|
||||
- **THEN** the breakdown SHALL render exactly as before this change (no External line)
|
||||
|
||||
#### Scenario: External-only spec shows only the External line
|
||||
- **WHEN** a specialization has `external = 9` and no in-program contributions
|
||||
- **THEN** the breakdown SHALL contain a single `External — 9.0` line
|
||||
@@ -0,0 +1,73 @@
|
||||
## 1. Solver: external-credit-aware feasibility and bounds
|
||||
|
||||
- [x] 1.1 In `app/src/solver/feasibility.ts`, extend `checkFeasibility` to accept `externalCredits?: Record<string, number>`. Before building per-spec demand constraints, compute `adjusted = max(0, CREDIT_THRESHOLD - (externalCredits[specId] ?? 0))`. If `adjusted === 0`, omit both the `need_<spec>` constraint and any `x_<course>_<spec>` variables for that spec entirely. Otherwise set `need_<spec> = { min: adjusted }`
|
||||
- [x] 1.2 Decide how to count externally-met specs in `result.allocations`: emit no in-program allocations for them (the LP doesn't allocate to them), and let `optimizer.determineStatuses` mark them as `achieved` based on the achieved-set rather than allocation totals. Document this in a one-line comment in `feasibility.ts`
|
||||
- [x] 1.3 Extend `computeUpperBounds` to accept `externalCredits?: Record<string, number>` and add `(externalCredits[spec.id] ?? 0)` to each spec's potential before storing the bound
|
||||
- [x] 1.4 Extend `preFilterCandidates` to accept `externalCredits?: Record<string, number>` and use the boosted potential when comparing against `CREDIT_THRESHOLD`
|
||||
|
||||
## 2. Solver: optimizer ceiling and status
|
||||
|
||||
- [x] 2.1 In `app/src/solver/optimizer.ts`, thread `externalCredits?: Record<string, number>` through `maximizeCount`, `priorityOrder`, `determineStatuses`, `optimize`, and `checkWithS2`. All call sites pass through (workers, app state)
|
||||
- [x] 2.2 Keep the hard 3-spec cap in `maximizeCount` (`const maxSize = Math.min(3, achievable.length)`). External credits may substitute into the 3 but never raise the count above 3 (program policy)
|
||||
- [x] 2.3 In `determineStatuses`, ensure `missing_required` precedence is preserved: when `spec.requiredCourseId` is unsatisfied, status remains `missing_required` regardless of `externalCredits[spec.id]`
|
||||
- [x] 2.4 Keep `priorityOrder`'s `if (achieved.length >= 3) break;` guard alongside the maxSize cap, mirroring `maximizeCount`
|
||||
|
||||
## 3. Solver: decision tree + worker plumbing
|
||||
|
||||
- [x] 3.1 In `app/src/solver/decisionTree.ts`, thread `externalCredits?: Record<string, number>` into `searchDecisionTree`, `evaluateLeaf`, `reorderForTarget`, `reorderByReachableQualCount`, and any helper that calls `computeUpperBounds`/`optimize`/`checkFeasibility`. Default to `{}` when absent
|
||||
- [x] 3.2 In `app/src/workers/decisionTree.worker.ts`, extend `WorkerRequest` with `externalCredits: Record<string, number>` and pass it through to `searchDecisionTree`
|
||||
|
||||
## 4. App state: external credits storage and cache invalidation
|
||||
|
||||
- [x] 4.1 In `app/src/state/appState.ts`, extend `AppState` with `externalCredits: Record<string, number>`
|
||||
- [x] 4.2 Add reducer action `{ type: 'setExternalCredit'; specId: string; credits: number }`. The reducer SHALL clamp negatives and `NaN` to `0`, treat `0` as deletion (omit the key from the new map), and produce an immutable update
|
||||
- [x] 4.3 Update `defaultState()` to include `externalCredits: {}`. Update `loadState()` to tolerate missing/invalid `externalCredits` (default to `{}`)
|
||||
- [x] 4.4 Extend the leaf-cache `LeafCache` type with `externalCredits: Record<string, number>` and the cache-invalidation check (currently comparing `ranking` and `mode`) to also compare external credits via deterministic stringification of sorted non-zero entries
|
||||
- [x] 4.5 Extend `pinnedAssignments` and the worker `WorkerRequest` build to include `externalCredits`. Pass `externalCredits` into the main-thread `optimize(...)` call as well
|
||||
- [x] 4.6 Export a `setExternalCredit` callback from `useAppState`
|
||||
|
||||
## 5. UI: bar segment and breakdown
|
||||
|
||||
- [x] 5.1 In `app/src/components/SpecializationRanking.tsx`, extend `CreditBar` props with `external: number` (default `0`)
|
||||
- [x] 5.2 Update `maxWidth` to `Math.max(potential + external, threshold)`
|
||||
- [x] 5.3 Render the amber stripe (`#f59e0b`) at the leftmost position with width `(external / maxWidth) * 100%`. The existing potential and allocated stripes shift to start at `external / maxWidth` (potential ends at `(external + potential) / maxWidth`; allocated ends at `(external + allocated) / maxWidth`). Tick marks and threshold marker positions remain expressed in absolute credits, scaled by `maxWidth`
|
||||
- [x] 5.4 Switch the allocated stripe color to green (`#22c55e`) when `(allocated + external) >= threshold`, blue (`#3b82f6`) otherwise
|
||||
- [x] 5.5 In `AllocationBreakdown`, accept `external: number` and prepend a `External` line item when `external > 0`. Use a small visual cue (italic label, amber accent text, or border) so it is identifiable without a legend
|
||||
- [x] 5.6 Pass `external` through from the spec card render path to both `CreditBar` and `AllocationBreakdown`
|
||||
|
||||
## 6. UI: inline editable credits chip
|
||||
|
||||
- [x] 6.1 Add an `ExternalCreditChip` component (in `SpecializationRanking.tsx` or a new file under `app/src/components/`) that takes `value`, `onChange(next: number)`, and renders the display state (`+<value>` or blank) and an inline numeric input on click/tap
|
||||
- [x] 6.2 Implement commit-on-blur and commit-on-Enter; clamp invalid input to `0`; allow decimals; reject negatives by clamping
|
||||
- [x] 6.3 Wire the chip into both the desktop chip layout and the mobile row layout in `SpecializationRanking.tsx`. Position so it does not displace drag handle, status badge, or rank number
|
||||
- [x] 6.4 Wire the chip's `onChange` to the new `setExternalCredit` callback from `useAppState`
|
||||
|
||||
## 7. Tests
|
||||
|
||||
- [x] 7.1 In `app/src/solver/__tests__/feasibility.test.ts` (new or existing), add tests:
|
||||
- LP demand reduction: 4.0 external + 5.0 in-program-feasible → feasible; without the 4.0 external, same in-program selection is infeasible
|
||||
- External alone meets threshold: 9.0 external → spec is feasible with no in-program allocation
|
||||
- Empty external credits preserves prior behavior on a representative scenario
|
||||
- Upper-bound boost: spec with in-program potential 6.0 and external 5.0 yields bound 11.0
|
||||
- `preFilterCandidates` includes spec with insufficient in-program potential when external closes the gap
|
||||
- [x] 7.2 In `app/src/solver/__tests__/optimizer.test.ts` (new or existing), add tests:
|
||||
- Hard 3-spec cap holds with and without external credits
|
||||
- External credits can substitute into the 3-spec set (e.g., HCR via 9 external)
|
||||
- `missing_required` survives external-only achievement: BRM with 9.0 external but no Brand Strategy stays `missing_required` and is not in the achieved set
|
||||
- `priorityOrder` respects the same external-credit math and the 3-spec cap
|
||||
- [x] 7.3 Add a state-layer test (or extend existing app-state tests) confirming the leaf cache is cleared when `externalCredits` changes and is retained when it does not
|
||||
- [x] 7.4 Run full suite; confirm all prior tests still pass
|
||||
|
||||
## 8. Browser verification
|
||||
|
||||
- [ ] 8.1 Start dev server. Click the chip on a non-required-course spec (e.g., Banking), enter `2.5`. Bar updates with amber segment; achievement count and per-spec status update accordingly
|
||||
- [ ] 8.2 Increase external credits to `9.0`. Spec shows as `achieved` with the amber segment crossing the threshold tick. Allocation breakdown shows only the External line
|
||||
- [ ] 8.3 Click the chip on a required-course-gated spec (e.g., BRM) without selecting Brand Strategy, enter `9.0`. Spec stays in `missing_required`. Bar shows full amber but status badge is unchanged
|
||||
- [ ] 8.4 Combine: pin courses for an in-program 3-spec achievement, then add `9.0` external to a fourth spec. Verify the optimizer still reports 3 (cap holds) but the chosen 3 may shift to include the externally-credited spec
|
||||
- [ ] 8.5 Reload page. External credit values persist. Cache invalidation visible (next search re-runs)
|
||||
- [ ] 8.6 Confirm no console errors; verify the chip is reachable and editable on both desktop and mobile layouts
|
||||
|
||||
## 9. Version + changelog
|
||||
|
||||
- [x] 9.1 Bump `__APP_VERSION__` and `__APP_VERSION_DATE__` in `app/vite.config.ts` to the next release (e.g., `1.4.1`)
|
||||
- [x] 9.2 Add a `CHANGELOG.md` entry: external credits per spec via inline chip, amber bar segment, achievement coloring on combined credit, lifted 3-spec ceiling, required-course gates unchanged
|
||||
Reference in New Issue
Block a user