Compare commits
8 Commits
0beafb58b5
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| cb2024f857 | |||
| 3a5ebaa17a | |||
| 2ebfb9d2ec | |||
| b282709476 | |||
| ee7ea352c4 | |||
| cb49123930 | |||
| 4b80fac500 | |||
| 4d6f81d1e5 |
@@ -1,5 +1,88 @@
|
||||
# Changelog
|
||||
|
||||
## v1.5.1 — 2026-05-10
|
||||
|
||||
### Changes
|
||||
|
||||
- **Top Plans surfaces the completed plan** — once every elective set is pinned, the panel renders the user's completed selection as a single "Your Plan" card showing the achievement count and the 12 pinned courses. Previously the panel went blank with a misleading "No plans yet…" placeholder, even though the spec strip was already showing the achievement.
|
||||
- **0-spec completed plan still renders** — when the pinned selection happens to achieve no specializations, the plan card now shows `0` achievements honestly instead of falling back to the search-style placeholder.
|
||||
- **Header copy** — the panel header switches to "Your Plan" (singular, no "ranked by specs achieved" subtitle) when selection is complete; reverts to "Top Plans" on unpin.
|
||||
|
||||
## 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
|
||||
|
||||
- **Desktop layout redesigned** — specializations move from a 340px left rail into a horizontal drag-to-rank chip strip at the top of the page (left = highest priority). The previous right pane splits into two side-by-side columns: Top Plans on the left, Schedule on the right. Each column scrolls independently so the schedule no longer gets pushed below the fold when plans expand.
|
||||
- **Search progress hoisted to a global strip** — the animated progress bar moves out of Top Plans into a thin strip directly below the spec strip. It stays visible regardless of which column you're scrolling. The static "Search complete · N explored" / "Search incomplete · cap hit at N" text remains inline with the Top Plans header.
|
||||
- **Specialization chips with hover popover** — each chip shows rank, full specialization name (line-clamped to 2 lines), status indicator, and a micro credit bar; status is encoded by background color. Hovering (or tapping on touch) opens a popover with the full name, status word, allocated/threshold credits, and — for achieved specializations — the contributing-courses breakdown. The strip scrolls horizontally when the 15 chips don't fit in the available width.
|
||||
- **Horizontal drag-to-reorder on desktop** — switched the spec sort strategy from `verticalListSortingStrategy` to `horizontalListSortingStrategy`. Mobile keeps vertical drag.
|
||||
- **Schedule blocks render horizontal course buttons on desktop** — each non-pinned elective set lays its course choices out as a flex row of equal-width buttons (3–5 per set) instead of stacked rows. Each button shows the info icon (top-left), the recommended star (top-right when applicable), the course name with line-clamp, and a row of spec ceiling tags at the bottom. Cancelled / already-selected / per-course searching states preserve their semantics in the new layout.
|
||||
- **Compact pinned-set rendering** — when a course is pinned, the elective-set card collapses to a single line: `Set Name: Course Name [Clear]`. The previous separate-row pinned-view block is gone, freeing vertical space.
|
||||
- **Mobile (≤640px) layout unchanged** — vertical specialization list, stacked Top Plans + Schedule sections, in-line progress bar, MobileStatusBanner and MobileCourseBanner all behave as before.
|
||||
|
||||
## v1.3.3 — 2026-05-09
|
||||
|
||||
### Changes
|
||||
|
||||
- **Lexicographic priority comparison** — fixes a scoring bug where combinations of lower-priority specializations could outrank a single higher-priority specialization in priority-order mode. The comparator now uses lex-by-rank: a plan containing a higher-ranked specialization always beats a plan that doesn't, regardless of how many lower-ranked specializations the latter contains. Lower-ranked specializations only act as tiebreakers among plans that all contain the same higher-ranked specs. Same logic also tiebreaks within maximize-count mode.
|
||||
- **Score display matches the comparator** — the per-plan score now shows the lexicographic rank weight in compact form (e.g. `score 24.6k`) instead of the legacy sum-of-weights. Hover the score for the full integer.
|
||||
- **Cache cap retains warm entries** — when the leaf cache hits the 500k cap, new entries are now dropped instead of clearing the cache; the existing 500k stay as a starting point for subsequent pin/unpin operations.
|
||||
- **Cache stays valid** across the comparator change — leaves cached under v1.3.2 still produce correct rankings under the new comparator since `achievedSpecs` (the input to lex compare) is unchanged.
|
||||
|
||||
## v1.3.2 — 2026-05-09
|
||||
|
||||
### Changes
|
||||
|
||||
- **Leaf cache for instant pin/unpin** — decision-tree leaf outcomes are now cached on the main thread keyed by their full 12-course assignment. Pin operations filter the cache and re-derive the top-K + per-set ceilings instantly with no worker spawn. Unpin operations show the cached subset immediately and stream improvements as a background worker fills in the missing leaves. The cache persists across pin, unpin, and adopt-plan operations.
|
||||
- **Cache invalidation** — the cache is cleared only when the active mode or the specialization ranking changes. Pin/unpin alone never invalidates.
|
||||
- **`skipKeys` worker contract** — workers now accept a list of cached assignment keys and skip the optimizer call for any leaf already in the cache, while still counting iterations toward the global progress percentage.
|
||||
- **`leafEvaluated` worker event** — workers stream individual leaf outcomes to the main thread for cache population as the search progresses.
|
||||
- **`deriveFromLeaves` shared helper** — pure function that produces the top-K and per-set ceilings from a leaf collection; used by both the main-thread cache filter and the worker's final emission for parity.
|
||||
- **500,000-leaf soft cap** — the cache is cleared if it grows beyond 500k entries, bounding worst-case memory at ~150 MB. Typical sessions stay well below.
|
||||
|
||||
## v1.3.1 — 2026-05-09
|
||||
|
||||
### Changes
|
||||
|
||||
- **Exhaustive decision-tree search** — replaced the saturation early-termination with full enumeration of the open-set cartesian product. Per-set ceiling cells now reflect the true best outcome for every (set, course) pair instead of leaving most cells stuck at "0 specs". Top Plans surfaces all genuinely-feasible plans, including 3-spec maximize-count plans that the v1.3.0 search missed. The previous iteration cap has been removed; search runs to full completion.
|
||||
- **Mode-dependent enumeration ordering** — priority-order mode keeps the priority-target-first heuristic; maximize-count mode now orders DFS children by descending count of qualifications for *reachable* specializations, surfacing generalist courses (e.g., Climate Finance with 6 qualifications) before specialists.
|
||||
- **Mode-aware comparator** — top-K and per-cell ceiling rankings now match the active mode: priority-order ranks by `(priorityScore, count)` so the top-priority spec surfaces; maximize-count ranks by `(count, priorityScore)` so the highest count wins. Recommended badges follow the same rule.
|
||||
- **"Recommended" badge per set** — each elective set now highlights the choice with the best ceiling outcome under the current mode. Rendered inline next to the course name to keep button height stable.
|
||||
- **Color-coded spec tags** — the per-cell outcome list and the Top Plans badges now use a fixed per-spec color palette so each specialization is visually identifiable at a glance.
|
||||
- **"Top outcome if picked ↓" caption** — added a small column header on each open elective set so the spec tags are clearly identified as decision-tree outcomes (not the course's own qualifications).
|
||||
- **Visual progress bar** — Top Plans header now shows a progress bar with `iterations / total · NN%` while the search runs, replacing the earlier text-only count.
|
||||
- **Per-cell streaming indicators** — courses that haven't been evaluated yet show a "searching" pulse instead of misleading "0 specs"; cells transition to their final value as the search completes.
|
||||
- **Per-set spinner** — each elective set heading shows a spinner while at least one of its choices is still unevaluated.
|
||||
|
||||
## v1.3.0 — 2026-05-09
|
||||
|
||||
### Changes
|
||||
|
||||
- **Top Plans panel** — new ranked list of up to 10 complete course plans, each showing the achieved specializations and the courses to pin. An "Adopt plan" button pins all of a plan's courses in one click. Updates progressively as the search finds better outcomes.
|
||||
- **Priority-aware decision tree** — fixes the bug where a specialization could show "Achievable" without any per-set ceiling cell surfacing it. The decision-tree search now compares enumerated combinations by `(count desc, priority score desc)` and reorders DFS children so courses qualifying for the user's first reachable ranked spec are tried first, surfacing high-priority outcomes early.
|
||||
- **Bounded search with saturation termination** — search stops when the top-K stabilizes (default 500 stable iterations) or when the iteration cap (10000) is hit; partial results are flagged in the UI.
|
||||
- **Per-cell streaming** — the worker now emits per-cell ceiling updates instead of per-set rollups, so the per-set table refines progressively rather than appearing in coarse chunks.
|
||||
|
||||
## v1.2.2 — 2026-05-09
|
||||
|
||||
### Changes
|
||||
|
||||
- **Healthcare specialization (HCR)** — added 15th specialization, Healthcare; qualifies via The Business of Health & Medical Care (Spring Set 2), Analytics & Machine Learning for Managers (Spring Set 3), Digital Marketing Strategy in Practice (Summer Set 2), and Managing Change (Fall Set 1); 10 total credits available, no required course gate
|
||||
- **Course rename** — "Social Media and Mobile Technology" renamed to "Digital Marketing Strategy in Practice"; description replaced with new MSKCC-anchored content covering digital strategy and agentic AI; instructor cleared pending confirmation
|
||||
- **Cancellations (Approach B)** — switched from delete-and-replace to flagging cancelled courses with `cancelled: true`. "Customer Insights" (Spring Set 5) is now marked cancelled. "Managing Growing Companies" reappears in Summer Set 2 as a cancelled placeholder per the J27 sheet
|
||||
- **Reachability test** — `data.test.ts` now excludes cancelled courses when counting per-spec set reachability, so future cancellations are caught by an obvious assertion failure
|
||||
|
||||
## v1.2.1 — 2026-03-27
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
+146
-65
@@ -1,21 +1,26 @@
|
||||
import { useMemo, useRef, useState, useEffect, useCallback } from 'react';
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
import { useAppState } from './state/appState';
|
||||
import { useMediaQuery } from './hooks/useMediaQuery';
|
||||
import { SpecializationRanking } from './components/SpecializationRanking';
|
||||
import { ModeToggle } from './components/ModeToggle';
|
||||
import { CourseSelection } from './components/CourseSelection';
|
||||
import { CreditLegend } from './components/CreditLegend';
|
||||
import { TopPlans } from './components/TopPlans';
|
||||
import { SearchProgressStrip } from './components/SearchProgressStrip';
|
||||
import { ModeComparison } from './components/Notifications';
|
||||
import { MobileStatusBanner } from './components/MobileStatusBanner';
|
||||
import { MobileCourseBanner } from './components/MobileCourseBanner';
|
||||
import { optimize } from './solver/optimizer';
|
||||
|
||||
type MobileTab = 'specs' | 'plans' | 'courses';
|
||||
|
||||
function App() {
|
||||
const {
|
||||
state,
|
||||
optimizationResult,
|
||||
treeResults,
|
||||
treeLoading,
|
||||
topPlans,
|
||||
topPlansPartial,
|
||||
searchProgress,
|
||||
openSetIds,
|
||||
selectedCourseIds,
|
||||
disabledCourseIds,
|
||||
@@ -24,7 +29,9 @@ function App() {
|
||||
setMode,
|
||||
pinCourse,
|
||||
unpinCourse,
|
||||
setExternalCredit,
|
||||
clearAll,
|
||||
adoptPlan,
|
||||
} = useAppState();
|
||||
|
||||
const breakpoint = useMediaQuery();
|
||||
@@ -32,49 +39,15 @@ 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';
|
||||
|
||||
const [mobileTab, setMobileTab] = useState<MobileTab>('specs');
|
||||
const specSectionRef = useRef<HTMLDivElement>(null);
|
||||
const [bannerVisible, setBannerVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMobile || !specSectionRef.current) {
|
||||
setBannerVisible(false);
|
||||
return;
|
||||
}
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => setBannerVisible(!entry.isIntersecting),
|
||||
);
|
||||
observer.observe(specSectionRef.current);
|
||||
return () => observer.disconnect();
|
||||
}, [isMobile]);
|
||||
|
||||
const handleBannerTap = useCallback(() => {
|
||||
specSectionRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, []);
|
||||
|
||||
const courseSectionRef = useRef<HTMLDivElement>(null);
|
||||
const [courseBannerVisible, setCourseBannerVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMobile || !courseSectionRef.current) {
|
||||
setCourseBannerVisible(false);
|
||||
return;
|
||||
}
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => setCourseBannerVisible(!entry.isIntersecting),
|
||||
);
|
||||
observer.observe(courseSectionRef.current);
|
||||
return () => observer.disconnect();
|
||||
}, [isMobile]);
|
||||
|
||||
const handleCourseBannerTap = useCallback(() => {
|
||||
courseSectionRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, []);
|
||||
|
||||
const containerStyle: React.CSSProperties = {
|
||||
maxWidth: '1200px',
|
||||
@@ -84,27 +57,116 @@ function App() {
|
||||
...(isMobile ? {} : { height: '100vh', display: 'flex', flexDirection: 'column', overflow: 'hidden' }),
|
||||
};
|
||||
|
||||
const panelStyle: React.CSSProperties = isMobile
|
||||
? { display: 'flex', flexDirection: 'column', gap: '20px' }
|
||||
: { display: 'grid', gridTemplateColumns: '340px 1fr', gap: '24px', alignItems: 'stretch', flex: 1, minHeight: 0 };
|
||||
if (isMobile) {
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
<h1 style={{ fontSize: '20px', marginBottom: '2px', color: '#111' }}>
|
||||
EMBA Specialization Solver
|
||||
</h1>
|
||||
<div style={{ fontSize: '11px', color: '#999', marginBottom: '12px' }}>v{__APP_VERSION__} ({__APP_VERSION_DATE__})</div>
|
||||
|
||||
<ModeToggle mode={state.mode} onSetMode={setMode} />
|
||||
|
||||
<ModeComparison
|
||||
result={optimizationResult}
|
||||
altResult={altResult}
|
||||
altModeName={altMode === 'maximize-count' ? 'Maximize Count' : 'Priority Order'}
|
||||
/>
|
||||
|
||||
<SearchProgressStrip loading={treeLoading} progress={searchProgress} />
|
||||
|
||||
<div role="tablist" aria-label="Sections" style={{
|
||||
display: 'grid', gridTemplateColumns: '1fr 1fr 1fr',
|
||||
gap: '4px', marginBottom: '12px',
|
||||
background: '#f1f5f9', borderRadius: '8px', padding: '3px',
|
||||
}}>
|
||||
{([
|
||||
{ id: 'specs', label: 'Specializations' },
|
||||
{ id: 'plans', label: 'Plans' },
|
||||
{ id: 'courses', label: 'Courses' },
|
||||
] as const).map((t) => {
|
||||
const active = mobileTab === t.id;
|
||||
return (
|
||||
<button
|
||||
key={t.id}
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
onClick={() => setMobileTab(t.id)}
|
||||
style={{
|
||||
fontSize: '12px', fontWeight: 600,
|
||||
padding: '8px 4px',
|
||||
border: 'none', borderRadius: '6px',
|
||||
background: active ? '#fff' : 'transparent',
|
||||
color: active ? '#1e293b' : '#64748b',
|
||||
boxShadow: active ? '0 1px 2px rgba(0,0,0,0.08)' : 'none',
|
||||
cursor: 'pointer',
|
||||
transition: 'background 150ms, color 150ms',
|
||||
}}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{mobileTab === 'specs' && (
|
||||
<div role="tabpanel" ref={specSectionRef}>
|
||||
<CreditLegend />
|
||||
<SpecializationRanking
|
||||
ranking={state.ranking}
|
||||
result={optimizationResult}
|
||||
externalCredits={state.externalCredits}
|
||||
onReorder={reorder}
|
||||
onSetExternalCredit={setExternalCredit}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{mobileTab === 'plans' && (
|
||||
<div role="tabpanel">
|
||||
<TopPlans
|
||||
plans={topPlans}
|
||||
partial={topPlansPartial}
|
||||
loading={treeLoading}
|
||||
progress={searchProgress}
|
||||
pinnedCourses={state.pinnedCourses}
|
||||
ranking={state.ranking}
|
||||
showAnimatedBar={false}
|
||||
onAdopt={adoptPlan}
|
||||
onPin={pinCourse}
|
||||
onUnpin={unpinCourse}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{mobileTab === 'courses' && (
|
||||
<div role="tabpanel" ref={courseSectionRef}>
|
||||
<CourseSelection
|
||||
pinnedCourses={state.pinnedCourses}
|
||||
treeResults={treeResults}
|
||||
treeLoading={treeLoading}
|
||||
disabledCourseIds={disabledCourseIds}
|
||||
ranking={state.ranking}
|
||||
mode={state.mode}
|
||||
onPin={pinCourse}
|
||||
onUnpin={unpinCourse}
|
||||
onClearAll={clearAll}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Desktop layout: top spec strip + global progress strip + 2-col workspace
|
||||
const workspaceStyle: React.CSSProperties = {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr',
|
||||
gap: '24px',
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
{isMobile && (
|
||||
<>
|
||||
<MobileStatusBanner
|
||||
statuses={optimizationResult.statuses}
|
||||
visible={bannerVisible}
|
||||
onTap={handleBannerTap}
|
||||
/>
|
||||
<MobileCourseBanner
|
||||
selectedCount={Object.keys(state.pinnedCourses).length}
|
||||
totalSets={12}
|
||||
visible={courseBannerVisible}
|
||||
onTap={handleCourseBannerTap}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<h1 style={{ fontSize: '20px', marginBottom: '2px', color: '#111' }}>
|
||||
EMBA Specialization Solver
|
||||
</h1>
|
||||
@@ -118,21 +180,40 @@ function App() {
|
||||
altModeName={altMode === 'maximize-count' ? 'Maximize Count' : 'Priority Order'}
|
||||
/>
|
||||
|
||||
<div style={panelStyle}>
|
||||
<div ref={specSectionRef} style={isMobile ? {} : { overflowY: 'auto', minHeight: 0 }}>
|
||||
<CreditLegend />
|
||||
<SpecializationRanking
|
||||
<SpecializationRanking
|
||||
ranking={state.ranking}
|
||||
result={optimizationResult}
|
||||
externalCredits={state.externalCredits}
|
||||
onReorder={reorder}
|
||||
onSetExternalCredit={setExternalCredit}
|
||||
headerSlot={<CreditLegend />}
|
||||
/>
|
||||
|
||||
<SearchProgressStrip loading={treeLoading} progress={searchProgress} />
|
||||
|
||||
<div style={workspaceStyle}>
|
||||
<div style={{ overflowY: 'auto', minHeight: 0, paddingRight: '4px' }}>
|
||||
<TopPlans
|
||||
plans={topPlans}
|
||||
partial={topPlansPartial}
|
||||
loading={treeLoading}
|
||||
progress={searchProgress}
|
||||
pinnedCourses={state.pinnedCourses}
|
||||
ranking={state.ranking}
|
||||
result={optimizationResult}
|
||||
onReorder={reorder}
|
||||
showAnimatedBar={false}
|
||||
onAdopt={adoptPlan}
|
||||
onPin={pinCourse}
|
||||
onUnpin={unpinCourse}
|
||||
/>
|
||||
</div>
|
||||
<div ref={courseSectionRef} style={isMobile ? {} : { overflowY: 'auto', minHeight: 0 }}>
|
||||
<div style={{ overflowY: 'auto', minHeight: 0, paddingRight: '4px' }}>
|
||||
<CourseSelection
|
||||
pinnedCourses={state.pinnedCourses}
|
||||
treeResults={treeResults}
|
||||
treeLoading={treeLoading}
|
||||
disabledCourseIds={disabledCourseIds}
|
||||
ranking={state.ranking}
|
||||
mode={state.mode}
|
||||
onPin={pinCourse}
|
||||
onUnpin={unpinCourse}
|
||||
onClearAll={clearAll}
|
||||
|
||||
@@ -1,13 +1,33 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import { ELECTIVE_SETS } from '../data/electiveSets';
|
||||
import { SPECIALIZATIONS } from '../data/specializations';
|
||||
import { coursesBySet } from '../data/lookups';
|
||||
import { COURSE_DESCRIPTIONS } from '../data/courseDescriptions';
|
||||
import { courseById } from '../data/lookups';
|
||||
import { useMediaQuery } from '../hooks/useMediaQuery';
|
||||
import type { Term } from '../data/types';
|
||||
import { makePriorityScorer, makePriorityRankWeight } from '../solver/priority';
|
||||
import { specColor } from '../data/specColors';
|
||||
import type { OptimizationMode, Term } from '../data/types';
|
||||
import type { SetAnalysis } from '../solver/decisionTree';
|
||||
|
||||
function SpecTag({ specId }: { specId: string }) {
|
||||
const c = specColor(specId);
|
||||
return (
|
||||
<span
|
||||
title={specNameById[specId] ?? specId}
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
fontSize: '9px', fontWeight: 700, letterSpacing: '0.2px',
|
||||
padding: '1px 5px', borderRadius: '3px',
|
||||
background: c.bg, color: c.fg, border: `1px solid ${c.border}`,
|
||||
whiteSpace: 'nowrap', lineHeight: '1.3',
|
||||
}}
|
||||
>
|
||||
{specId}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Reverse map: courseId → specialization names that require it
|
||||
const requiredForSpec: Record<string, string[]> = {};
|
||||
for (const spec of SPECIALIZATIONS) {
|
||||
@@ -179,6 +199,8 @@ interface CourseSelectionProps {
|
||||
treeResults: SetAnalysis[];
|
||||
treeLoading: boolean;
|
||||
disabledCourseIds: Set<string>;
|
||||
ranking: string[];
|
||||
mode: OptimizationMode;
|
||||
onPin: (setId: string, courseId: string) => void;
|
||||
onUnpin: (setId: string) => void;
|
||||
onClearAll: () => void;
|
||||
@@ -191,6 +213,10 @@ function ElectiveSet({
|
||||
analysis,
|
||||
loading,
|
||||
disabledCourseIds,
|
||||
scorer,
|
||||
rankWeight,
|
||||
mode,
|
||||
isMobile,
|
||||
onPin,
|
||||
onUnpin,
|
||||
openPopoverId,
|
||||
@@ -205,6 +231,10 @@ function ElectiveSet({
|
||||
analysis?: SetAnalysis;
|
||||
loading: boolean;
|
||||
disabledCourseIds: Set<string>;
|
||||
scorer: (specs: string[]) => number;
|
||||
rankWeight: (specs: string[]) => number;
|
||||
mode: OptimizationMode;
|
||||
isMobile: boolean;
|
||||
onPin: (courseId: string) => void;
|
||||
onUnpin: () => void;
|
||||
openPopoverId: string | null;
|
||||
@@ -223,25 +253,83 @@ function ElectiveSet({
|
||||
);
|
||||
const hasHighImpact = analysis && analysis.impact > 0;
|
||||
|
||||
// Determine the recommended choice. Mode-dependent comparison matches the
|
||||
// top-K comparator: priority-order ranks by (rankWeight, count); max-count by (count, rankWeight).
|
||||
// Lex rank weight: a single high-priority spec dominates any combination of lower ones.
|
||||
let recommendedCourseId: string | null = null;
|
||||
if (analysis && analysis.choices.length > 0) {
|
||||
let best: { id: string; count: number; weight: number } | null = null;
|
||||
for (const ch of analysis.choices) {
|
||||
if (!ch.evaluated) continue;
|
||||
const weight = rankWeight(ch.ceilingSpecs);
|
||||
const isBetter =
|
||||
!best ||
|
||||
(mode === 'priority-order'
|
||||
? weight > best.weight || (weight === best.weight && ch.ceilingCount > best.count)
|
||||
: ch.ceilingCount > best.count || (ch.ceilingCount === best.count && weight > best.weight));
|
||||
if (isBetter) {
|
||||
best = { id: ch.courseId, count: ch.ceilingCount, weight };
|
||||
}
|
||||
}
|
||||
if (best && (best.count > 0 || best.weight > 0)) recommendedCourseId = best.id;
|
||||
}
|
||||
|
||||
// Per-set still-searching indicator: search in progress AND at least one cell unevaluated
|
||||
const setSearching =
|
||||
loading && !!analysis && analysis.choices.some((c) => !c.evaluated);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
border: isPinned ? '1px solid #3b82f6' : '1px solid #ccc',
|
||||
borderStyle: isPinned ? 'solid' : 'dashed',
|
||||
borderRadius: '8px',
|
||||
padding: '12px',
|
||||
padding: isPinned ? '8px 12px' : '12px',
|
||||
marginBottom: '8px',
|
||||
background: isPinned ? '#eff6ff' : '#fafafa',
|
||||
transition: 'border-color 200ms, background-color 200ms',
|
||||
transition: 'border-color 200ms, background-color 200ms, padding 200ms',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' }}>
|
||||
<h4 style={{ fontSize: '13px', margin: 0, color: '#444' }}>
|
||||
{setName}
|
||||
<div style={{
|
||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||
gap: '8px',
|
||||
marginBottom: isPinned ? 0 : '8px',
|
||||
}}>
|
||||
<h4 style={{
|
||||
fontSize: '13px', margin: 0, color: '#444',
|
||||
display: 'flex', alignItems: 'baseline', gap: '8px', flexWrap: 'wrap',
|
||||
minWidth: 0, flex: 1,
|
||||
}}>
|
||||
<span style={{ flexShrink: 0 }}>{setName}{isPinned ? ':' : ''}</span>
|
||||
{isPinned && pinnedCourse && (
|
||||
<span style={{
|
||||
fontSize: '13px', fontWeight: 600, color: '#1e40af',
|
||||
minWidth: 0,
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{pinnedCourse.name}
|
||||
</span>
|
||||
)}
|
||||
{!isPinned && setSearching && (
|
||||
<span style={{
|
||||
display: 'inline-block', width: '10px', height: '10px', borderRadius: '50%',
|
||||
border: '2px solid #cbd5e1', borderTopColor: '#6366f1',
|
||||
animation: 'spin 0.8s linear infinite',
|
||||
}} aria-label="searching" />
|
||||
)}
|
||||
{!isPinned && hasHighImpact && (
|
||||
<span style={{ fontSize: '11px', color: '#d97706', marginLeft: '8px', fontWeight: 400 }}>high impact</span>
|
||||
<span style={{ fontSize: '11px', color: '#d97706', fontWeight: 400 }}>high impact</span>
|
||||
)}
|
||||
</h4>
|
||||
{!isPinned && isMobile && (
|
||||
<span style={{
|
||||
fontSize: '10px', color: '#94a3b8', fontWeight: 500, letterSpacing: '0.3px',
|
||||
textTransform: 'uppercase',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
top outcome if picked ↓
|
||||
</span>
|
||||
)}
|
||||
{isPinned && (
|
||||
<button
|
||||
onClick={onUnpin}
|
||||
@@ -249,23 +337,13 @@ function ElectiveSet({
|
||||
fontSize: '11px', border: '1px solid #bfdbfe', background: '#eff6ff',
|
||||
color: '#2563eb', cursor: 'pointer', padding: '3px 10px',
|
||||
borderRadius: '4px', fontWeight: 500,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* Pinned view */}
|
||||
<div style={{
|
||||
maxHeight: isPinned ? '40px' : '0',
|
||||
opacity: isPinned ? 1 : 0,
|
||||
overflow: 'hidden',
|
||||
transition: 'max-height 250ms ease-out, opacity 200ms',
|
||||
}}>
|
||||
<div style={{ fontSize: '14px', fontWeight: 600, color: '#1e40af' }}>
|
||||
{pinnedCourse?.name}
|
||||
</div>
|
||||
</div>
|
||||
{/* Course list view */}
|
||||
<div style={{
|
||||
maxHeight: isPinned ? '0' : '500px',
|
||||
@@ -274,7 +352,12 @@ function ElectiveSet({
|
||||
pointerEvents: isPinned ? 'none' : 'auto',
|
||||
transition: 'max-height 250ms ease-out, opacity 200ms',
|
||||
}}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: isMobile ? 'column' : 'row',
|
||||
alignItems: isMobile ? 'stretch' : 'stretch',
|
||||
gap: '4px',
|
||||
}}>
|
||||
{courses.map((course) => {
|
||||
const isCancelled = !!course.cancelled;
|
||||
const isDisabled = disabledCourseIds.has(course.id);
|
||||
@@ -282,124 +365,227 @@ function ElectiveSet({
|
||||
const ceiling = ceilingMap.get(course.id);
|
||||
const reqFor = requiredForSpec[course.id];
|
||||
const showSkeleton = loading && !analysis;
|
||||
const cellSearching = !!ceiling && !ceiling.evaluated;
|
||||
const isRecommended = recommendedCourseId === course.id;
|
||||
const hasInfo = !!COURSE_DESCRIPTIONS[course.id];
|
||||
|
||||
const infoIcon = !isUnavailable && hasInfo ? (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`Info about ${course.name}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
if (openPopoverId === course.id) {
|
||||
onClosePopover();
|
||||
} else {
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
onOpenPopover(course.id, rect);
|
||||
}
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (window.matchMedia('(hover: hover)').matches) {
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
onHoverOpen(course.id, rect);
|
||||
}
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
if (window.matchMedia('(hover: hover)').matches) {
|
||||
onHoverLeave();
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
if (openPopoverId === course.id) {
|
||||
onClosePopover();
|
||||
} else {
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
onOpenPopover(course.id, rect);
|
||||
}
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: '16px', height: '16px', borderRadius: '50%',
|
||||
border: '1px solid #cbd5e1', background: openPopoverId === course.id ? '#e0e7ff' : '#f1f5f9',
|
||||
color: '#6366f1', fontSize: '10px', fontWeight: 700,
|
||||
cursor: 'pointer', flexShrink: 0,
|
||||
fontStyle: 'normal', textDecoration: 'none',
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
i
|
||||
</span>
|
||||
) : null;
|
||||
|
||||
const ceilingTags = !isUnavailable && (showSkeleton || cellSearching) ? (
|
||||
<span style={{
|
||||
fontSize: '11px', color: '#94a3b8', fontStyle: 'italic',
|
||||
display: 'inline-flex', alignItems: 'center', gap: '4px',
|
||||
}}>
|
||||
<span style={{
|
||||
display: 'inline-block', width: '8px', height: '8px', borderRadius: '50%',
|
||||
background: '#cbd5e1', animation: 'cell-pulse 1.2s ease-in-out infinite',
|
||||
}} />
|
||||
searching
|
||||
</span>
|
||||
) : !isUnavailable && ceiling && ceiling.evaluated ? (
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: '4px',
|
||||
flexWrap: 'wrap', justifyContent: isMobile ? 'flex-end' : 'flex-start',
|
||||
}}>
|
||||
{ceiling.ceilingSpecs.length === 0 ? (
|
||||
<span style={{ fontSize: '11px', color: '#9ca3af', fontStyle: 'italic' }}>
|
||||
no specs
|
||||
</span>
|
||||
) : (
|
||||
ceiling.ceilingSpecs.map((s) => <SpecTag key={s} specId={s} />)
|
||||
)}
|
||||
</span>
|
||||
) : null;
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<button
|
||||
key={course.id}
|
||||
onClick={isUnavailable ? undefined : () => onPin(course.id)}
|
||||
disabled={isUnavailable}
|
||||
style={{
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'stretch',
|
||||
textAlign: 'left', padding: '6px 10px',
|
||||
border: '1px solid #e5e7eb', borderRadius: '4px',
|
||||
background: isUnavailable ? '#f5f5f5' : '#fff',
|
||||
cursor: isUnavailable ? 'default' : 'pointer',
|
||||
fontSize: '13px',
|
||||
color: isUnavailable ? '#bbb' : '#333',
|
||||
pointerEvents: isUnavailable ? 'none' : 'auto',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{
|
||||
flex: 1,
|
||||
textDecoration: isCancelled ? 'line-through' : 'none',
|
||||
fontStyle: isCancelled ? 'italic' : 'normal',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
}}>
|
||||
{course.name}
|
||||
{isCancelled && (
|
||||
<span style={{ fontSize: '11px', color: '#999', marginLeft: '6px', fontStyle: 'normal', textDecoration: 'none' }}>
|
||||
(Cancelled)
|
||||
</span>
|
||||
)}
|
||||
{!isCancelled && isDisabled && (
|
||||
<span style={{ fontSize: '11px', color: '#999', marginLeft: '6px' }}>
|
||||
(Already selected)
|
||||
</span>
|
||||
)}
|
||||
{isRecommended && !isUnavailable && (
|
||||
<span
|
||||
title="Best outcome among the choices in this set"
|
||||
style={{
|
||||
fontSize: '10px', color: '#15803d', fontWeight: 600,
|
||||
display: 'inline-flex', alignItems: 'center', gap: '2px',
|
||||
padding: '0 4px', borderRadius: '3px', background: '#dcfce7',
|
||||
border: '1px solid #bbf7d0', lineHeight: 1.2,
|
||||
}}
|
||||
>
|
||||
<span aria-hidden="true">★</span>
|
||||
<span>Recommended</span>
|
||||
</span>
|
||||
)}
|
||||
{infoIcon}
|
||||
</span>
|
||||
{ceilingTags}
|
||||
</div>
|
||||
{reqFor && !isUnavailable && (
|
||||
<span style={{ fontSize: '11px', color: '#92400e', marginTop: '2px' }}>
|
||||
Required for {reqFor.join(', ')}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// Desktop horizontal button
|
||||
return (
|
||||
<button
|
||||
key={course.id}
|
||||
onClick={isUnavailable ? undefined : () => onPin(course.id)}
|
||||
disabled={isUnavailable}
|
||||
title={course.name}
|
||||
style={{
|
||||
flex: '1 1 0',
|
||||
minWidth: 0,
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'stretch',
|
||||
textAlign: 'left', padding: '6px 10px',
|
||||
border: '1px solid #e5e7eb', borderRadius: '4px',
|
||||
background: isUnavailable ? '#f5f5f5' : '#fff',
|
||||
textAlign: 'left', padding: '6px 8px', gap: '4px',
|
||||
border: isRecommended && !isUnavailable ? '1px solid #bbf7d0' : '1px solid #e5e7eb',
|
||||
borderRadius: '4px',
|
||||
background: isUnavailable ? '#f5f5f5' : isRecommended ? '#f0fdf4' : '#fff',
|
||||
cursor: isUnavailable ? 'default' : 'pointer',
|
||||
fontSize: '13px',
|
||||
fontSize: '12px',
|
||||
color: isUnavailable ? '#bbb' : '#333',
|
||||
pointerEvents: isUnavailable ? 'none' : 'auto',
|
||||
font: 'inherit',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{
|
||||
flex: 1,
|
||||
textDecoration: isCancelled ? 'line-through' : 'none',
|
||||
fontStyle: isCancelled ? 'italic' : 'normal',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
}}>
|
||||
{course.name}
|
||||
{isCancelled && (
|
||||
<span style={{ fontSize: '11px', color: '#999', marginLeft: '6px', fontStyle: 'normal', textDecoration: 'none' }}>
|
||||
(Cancelled)
|
||||
</span>
|
||||
)}
|
||||
{!isCancelled && isDisabled && (
|
||||
<span style={{ fontSize: '11px', color: '#999', marginLeft: '6px' }}>
|
||||
(Already selected)
|
||||
</span>
|
||||
)}
|
||||
{!isUnavailable && hasInfo && (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`Info about ${course.name}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
if (openPopoverId === course.id) {
|
||||
onClosePopover();
|
||||
} else {
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
onOpenPopover(course.id, rect);
|
||||
}
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (window.matchMedia('(hover: hover)').matches) {
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
onHoverOpen(course.id, rect);
|
||||
}
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
if (window.matchMedia('(hover: hover)').matches) {
|
||||
onHoverLeave();
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
if (openPopoverId === course.id) {
|
||||
onClosePopover();
|
||||
} else {
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
onOpenPopover(course.id, rect);
|
||||
}
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: '16px', height: '16px', borderRadius: '50%',
|
||||
border: '1px solid #cbd5e1', background: openPopoverId === course.id ? '#e0e7ff' : '#f1f5f9',
|
||||
color: '#6366f1', fontSize: '10px', fontWeight: 700,
|
||||
cursor: 'pointer', flexShrink: 0,
|
||||
fontStyle: 'normal', textDecoration: 'none',
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
i
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{!isUnavailable && showSkeleton ? (
|
||||
<div style={{
|
||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||
minHeight: '16px',
|
||||
}}>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center' }}>{infoIcon}</span>
|
||||
{isRecommended && !isUnavailable && (
|
||||
<span
|
||||
title="Best outcome among the choices in this set"
|
||||
aria-label="Recommended"
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
width: '60px',
|
||||
height: '14px',
|
||||
borderRadius: '3px',
|
||||
background: 'linear-gradient(90deg, #e5e7eb 25%, #f0f0f0 50%, #e5e7eb 75%)',
|
||||
backgroundSize: '200% 100%',
|
||||
animation: 'skeleton-pulse 1.5s ease-in-out infinite',
|
||||
fontSize: '12px', color: '#15803d', lineHeight: 1,
|
||||
}}
|
||||
/>
|
||||
) : !isUnavailable && ceiling ? (
|
||||
<span style={{
|
||||
fontSize: '11px', whiteSpace: 'nowrap', fontWeight: 600,
|
||||
color: ceiling.ceilingCount >= 3 ? '#16a34a' : ceiling.ceilingCount >= 2 ? '#2563eb' : '#666',
|
||||
}}>
|
||||
{ceiling.ceilingCount} spec{ceiling.ceilingCount !== 1 ? 's' : ''}
|
||||
{ceiling.ceilingSpecs.length > 0 && (
|
||||
<span style={{ fontWeight: 400, color: '#888', marginLeft: '3px' }}>
|
||||
({ceiling.ceilingSpecs.join(', ')})
|
||||
</span>
|
||||
)}
|
||||
>
|
||||
★
|
||||
</span>
|
||||
) : null}
|
||||
)}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
color: isUnavailable ? '#bbb' : '#333',
|
||||
fontWeight: isRecommended && !isUnavailable ? 600 : 500,
|
||||
lineHeight: 1.3,
|
||||
textDecoration: isCancelled ? 'line-through' : 'none',
|
||||
fontStyle: isCancelled ? 'italic' : 'normal',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 3,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
flex: 1,
|
||||
}}>
|
||||
{course.name}
|
||||
</div>
|
||||
{ceilingTags && (
|
||||
<div style={{
|
||||
display: 'flex', flexWrap: 'wrap', gap: '3px',
|
||||
marginTop: 'auto', minHeight: '18px',
|
||||
}}>
|
||||
{ceilingTags}
|
||||
</div>
|
||||
)}
|
||||
{isCancelled && (
|
||||
<span style={{ fontSize: '10px', color: '#999', fontStyle: 'normal', textDecoration: 'none' }}>
|
||||
Cancelled
|
||||
</span>
|
||||
)}
|
||||
{!isCancelled && isDisabled && (
|
||||
<span style={{ fontSize: '10px', color: '#999' }}>
|
||||
Already selected
|
||||
</span>
|
||||
)}
|
||||
{reqFor && !isUnavailable && (
|
||||
<span style={{ fontSize: '11px', color: '#92400e', marginTop: '2px' }}>
|
||||
Required for {reqFor.join(', ')}
|
||||
<span style={{ fontSize: '10px', color: '#92400e', lineHeight: 1.3 }}>
|
||||
Req. for {reqFor.join(', ')}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
@@ -411,11 +597,19 @@ function ElectiveSet({
|
||||
);
|
||||
}
|
||||
|
||||
const skeletonStyle = `@keyframes skeleton-pulse { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }`;
|
||||
const skeletonStyle = `
|
||||
@keyframes skeleton-pulse { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
|
||||
@keyframes cell-pulse { 0%, 100% { opacity: 0.4; } 50% { opacity: 1; } }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
`;
|
||||
|
||||
export function CourseSelection({ pinnedCourses, treeResults, treeLoading, disabledCourseIds, onPin, onUnpin, onClearAll }: CourseSelectionProps) {
|
||||
export function CourseSelection({ pinnedCourses, treeResults, treeLoading, disabledCourseIds, ranking, mode, onPin, onUnpin, onClearAll }: CourseSelectionProps) {
|
||||
const scorer = useMemo(() => makePriorityScorer(ranking), [ranking]);
|
||||
const rankWeight = useMemo(() => makePriorityRankWeight(ranking), [ranking]);
|
||||
const terms: Term[] = ['Spring', 'Summer', 'Fall'];
|
||||
const hasPinned = Object.keys(pinnedCourses).length > 0;
|
||||
const breakpoint = useMediaQuery();
|
||||
const isMobile = breakpoint === 'mobile';
|
||||
|
||||
// Index tree results by setId for O(1) lookup
|
||||
const treeBySet = new Map(treeResults.map((a) => [a.setId, a]));
|
||||
@@ -493,6 +687,10 @@ export function CourseSelection({ pinnedCourses, treeResults, treeLoading, disab
|
||||
analysis={treeBySet.get(set.id)}
|
||||
loading={treeLoading}
|
||||
disabledCourseIds={disabledCourseIds}
|
||||
scorer={scorer}
|
||||
rankWeight={rankWeight}
|
||||
mode={mode}
|
||||
isMobile={isMobile}
|
||||
onPin={(courseId) => onPin(set.id, courseId)}
|
||||
onUnpin={() => onUnpin(set.id)}
|
||||
openPopoverId={popover?.courseId ?? null}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
interface SearchProgressStripProps {
|
||||
loading: boolean;
|
||||
progress: { iterations: number; iterationsTotal: number } | null;
|
||||
}
|
||||
|
||||
function formatNum(n: number): string {
|
||||
return n.toLocaleString();
|
||||
}
|
||||
|
||||
export function SearchProgressStrip({ loading, progress }: SearchProgressStripProps) {
|
||||
if (!loading || !progress) return null;
|
||||
const pct = progress.iterationsTotal > 0
|
||||
? Math.min(100, (progress.iterations / progress.iterationsTotal) * 100)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div style={{ margin: '4px 0 12px' }}>
|
||||
<div style={{
|
||||
position: 'relative', height: '6px', background: '#e5e7eb',
|
||||
borderRadius: '3px', overflow: 'hidden',
|
||||
}}>
|
||||
<div style={{
|
||||
position: 'absolute', top: 0, left: 0, height: '100%',
|
||||
width: `${pct}%`, background: '#3b82f6',
|
||||
transition: 'width 150ms ease-out',
|
||||
}} />
|
||||
</div>
|
||||
<div style={{
|
||||
display: 'flex', justifyContent: 'space-between',
|
||||
fontSize: '10px', color: '#888', marginTop: '2px',
|
||||
}}>
|
||||
<span>Searching…</span>
|
||||
<span>
|
||||
{formatNum(progress.iterations)} / {formatNum(progress.iterationsTotal)}
|
||||
{' · '}{Math.round(pct)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
@@ -14,11 +14,14 @@ import {
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
rectSortingStrategy,
|
||||
arrayMove,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { SPECIALIZATIONS } from '../data/specializations';
|
||||
import { courseById } from '../data/lookups';
|
||||
import { specColor } from '../data/specColors';
|
||||
import { useMediaQuery } from '../hooks/useMediaQuery';
|
||||
import type { SpecStatus, AllocationResult } from '../data/types';
|
||||
|
||||
export const STATUS_STYLES: Record<SpecStatus, { bg: string; color: string; label: string }> = {
|
||||
@@ -28,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[] = [];
|
||||
@@ -44,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
|
||||
@@ -79,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];
|
||||
@@ -88,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>
|
||||
@@ -102,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;
|
||||
@@ -110,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,
|
||||
@@ -189,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={{
|
||||
@@ -202,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>
|
||||
);
|
||||
@@ -217,10 +340,16 @@ 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, onReorder }: SpecializationRankingProps) {
|
||||
export function SpecializationRanking({ ranking, result, externalCredits, headerSlot, onReorder, onSetExternalCredit }: SpecializationRankingProps) {
|
||||
const breakpoint = useMediaQuery();
|
||||
const isMobile = breakpoint === 'mobile';
|
||||
|
||||
const [expanded, setExpanded] = useState<Set<string>>(() => new Set(result.achieved));
|
||||
const prevAchievedRef = useRef(result.achieved);
|
||||
|
||||
@@ -271,6 +400,26 @@ export function SpecializationRanking({ ranking, result, onReorder }: Specializa
|
||||
}
|
||||
|
||||
const specMap = new Map(SPECIALIZATIONS.map((s) => [s.id, s]));
|
||||
const achievedSummary = result.achieved.length > 0
|
||||
? `${result.achieved.length} of ${ranking.length} achieved`
|
||||
: 'No specializations achieved yet';
|
||||
|
||||
if (!isMobile) {
|
||||
return (
|
||||
<DesktopSpecStrip
|
||||
ranking={ranking}
|
||||
result={result}
|
||||
externalCredits={externalCredits}
|
||||
sensors={sensors}
|
||||
onDragEnd={handleDragEnd}
|
||||
getAllocatedCredits={getAllocatedCredits}
|
||||
specMap={specMap}
|
||||
achievedSummary={achievedSummary}
|
||||
headerSlot={headerSlot}
|
||||
onSetExternalCredit={onSetExternalCredit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -293,11 +442,13 @@ export function SpecializationRanking({ ranking, result, onReorder }: Specializa
|
||||
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>
|
||||
@@ -305,3 +456,383 @@ export function SpecializationRanking({ ranking, result, onReorder }: Specializa
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== Desktop strip variant =====
|
||||
|
||||
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, 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);
|
||||
|
||||
const cancelHoverClose = useCallback(() => {
|
||||
if (hoverCloseTimer.current) {
|
||||
clearTimeout(hoverCloseTimer.current);
|
||||
hoverCloseTimer.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const openPopover = useCallback((specId: string, rect: DOMRect) => {
|
||||
cancelHoverClose();
|
||||
setPopover({ specId, anchorRect: rect });
|
||||
}, [cancelHoverClose]);
|
||||
|
||||
const closePopover = useCallback(() => {
|
||||
cancelHoverClose();
|
||||
setPopover(null);
|
||||
}, [cancelHoverClose]);
|
||||
|
||||
const handleHoverLeave = useCallback(() => {
|
||||
hoverCloseTimer.current = setTimeout(() => {
|
||||
setPopover(null);
|
||||
}, 150);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'baseline', justifyContent: 'space-between',
|
||||
gap: '12px', marginBottom: '6px', flexWrap: 'wrap',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: '12px', flexWrap: 'wrap' }}>
|
||||
<h2 style={{ fontSize: '16px', margin: 0 }}>Specializations</h2>
|
||||
<span style={{ fontSize: '11px', color: '#888' }}>
|
||||
drag left → right to rank · {achievedSummary}
|
||||
</span>
|
||||
</div>
|
||||
{headerSlot && <div style={{ display: 'flex', alignItems: 'baseline' }}>{headerSlot}</div>}
|
||||
</div>
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
|
||||
<SortableContext items={ranking} strategy={rectSortingStrategy}>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: `repeat(${Math.ceil(ranking.length / 2)}, minmax(0, 1fr))`,
|
||||
gap: '6px',
|
||||
paddingBottom: '4px',
|
||||
}}>
|
||||
{ranking.map((id, i) => (
|
||||
<SpecChip
|
||||
key={id}
|
||||
id={id}
|
||||
rank={i + 1}
|
||||
name={specMap.get(id)?.name ?? id}
|
||||
abbreviation={specMap.get(id)?.abbreviation ?? id}
|
||||
status={result.statuses[id]}
|
||||
allocated={getAllocatedCredits(id)}
|
||||
potential={result.upperBounds[id] || 0}
|
||||
external={externalCredits[id] ?? 0}
|
||||
isOpen={popover?.specId === id}
|
||||
onHoverOpen={openPopover}
|
||||
onHoverLeave={handleHoverLeave}
|
||||
onTapToggle={(specId, rect) => {
|
||||
if (popover?.specId === specId) closePopover();
|
||||
else openPopover(specId, rect);
|
||||
}}
|
||||
onSetExternal={(credits) => onSetExternalCredit(id, credits)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
{popover && (
|
||||
<SpecChipPopover
|
||||
specId={popover.specId}
|
||||
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}
|
||||
onHoverEnter={cancelHoverClose}
|
||||
onHoverLeave={handleHoverLeave}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SpecChipProps {
|
||||
id: string;
|
||||
rank: number;
|
||||
name: string;
|
||||
abbreviation: string;
|
||||
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, external, isOpen, onHoverOpen, onHoverLeave, onTapToggle, onSetExternal }: SpecChipProps) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition: dndTransition,
|
||||
isDragging,
|
||||
} = useSortable({ id });
|
||||
|
||||
const style = STATUS_STYLES[status] || STATUS_STYLES.unreachable;
|
||||
const tagColor = specColor(id);
|
||||
const threshold = 9;
|
||||
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) {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
onHoverOpen(id, rect);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
if (window.matchMedia('(hover: hover)').matches) {
|
||||
onHoverLeave();
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
onTapToggle(id, rect);
|
||||
};
|
||||
|
||||
const statusGlyph =
|
||||
status === 'achieved' ? '✓' :
|
||||
status === 'achievable' ? '·' :
|
||||
status === 'missing_required' ? '!' : '—';
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onClick={handleClick}
|
||||
title={name}
|
||||
style={{
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition: dndTransition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
minWidth: 0,
|
||||
height: '70px',
|
||||
borderRadius: '6px',
|
||||
background: isDragging ? '#e8e8e8' : style.bg,
|
||||
border: isOpen ? `2px solid ${style.color}` : '1px solid #ddd',
|
||||
padding: isOpen ? '5px 7px' : '6px 8px',
|
||||
cursor: 'grab',
|
||||
touchAction: 'none',
|
||||
userSelect: 'none',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'stretch',
|
||||
gap: '3px',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: '4px',
|
||||
fontSize: '11px', lineHeight: 1.2,
|
||||
}}>
|
||||
<span style={{ color: '#888', fontWeight: 500 }}>{rank}.</span>
|
||||
<span style={{
|
||||
display: 'inline-block',
|
||||
fontSize: '9px', fontWeight: 700, letterSpacing: '0.2px',
|
||||
padding: '1px 4px', borderRadius: '3px',
|
||||
background: tagColor.bg, color: tagColor.fg,
|
||||
border: `1px solid ${tagColor.border}`,
|
||||
whiteSpace: 'nowrap', lineHeight: '1.3',
|
||||
}}>
|
||||
{abbreviation}
|
||||
</span>
|
||||
<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={{
|
||||
fontSize: '11px', fontWeight: 600, color: '#333',
|
||||
lineHeight: 1.2,
|
||||
maxHeight: '2.4em',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}>
|
||||
{name}
|
||||
</div>
|
||||
<div style={{
|
||||
position: 'relative', height: '4px', background: '#e5e7eb',
|
||||
borderRadius: '2px', overflow: 'hidden', marginTop: 'auto',
|
||||
}}>
|
||||
{potential > allocated && (
|
||||
<div style={{
|
||||
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: `${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',
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SpecChipPopoverProps {
|
||||
specId: string;
|
||||
name: string;
|
||||
status: SpecStatus;
|
||||
allocated: number;
|
||||
external: number;
|
||||
allocations: Record<string, Record<string, number>>;
|
||||
anchorRect: DOMRect;
|
||||
onClose: () => void;
|
||||
onHoverEnter: () => void;
|
||||
onHoverLeave: () => void;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
useEffect(() => {
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') onClose();
|
||||
}
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
return () => document.removeEventListener('keydown', onKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
function onClickOutside(e: MouseEvent) {
|
||||
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
const id = setTimeout(() => document.addEventListener('mousedown', onClickOutside), 0);
|
||||
return () => {
|
||||
clearTimeout(id);
|
||||
document.removeEventListener('mousedown', onClickOutside);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
const popoverMaxHeight = 260;
|
||||
const popoverWidth = 280;
|
||||
const spaceBelow = window.innerHeight - anchorRect.bottom - 6;
|
||||
const spaceAbove = anchorRect.top - 6;
|
||||
const placeAbove = spaceBelow < Math.min(popoverMaxHeight, 150) && spaceAbove > spaceBelow;
|
||||
const left = Math.max(8, Math.min(anchorRect.left, window.innerWidth - popoverWidth - 8));
|
||||
|
||||
const positionStyle: React.CSSProperties = {
|
||||
position: 'fixed',
|
||||
left,
|
||||
width: popoverWidth,
|
||||
...(placeAbove
|
||||
? { bottom: window.innerHeight - anchorRect.top + 6, maxHeight: Math.min(popoverMaxHeight, spaceAbove) }
|
||||
: { top: anchorRect.bottom + 6, maxHeight: Math.min(popoverMaxHeight, spaceBelow) }),
|
||||
};
|
||||
|
||||
const contributions: { courseName: string; credits: number }[] = [];
|
||||
for (const [courseId, specAlloc] of Object.entries(allocations)) {
|
||||
const credits = specAlloc[specId];
|
||||
if (credits && credits > 0) {
|
||||
const course = courseById[courseId];
|
||||
contributions.push({ courseName: course?.name ?? courseId, credits });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={popoverRef}
|
||||
onMouseEnter={onHoverEnter}
|
||||
onMouseLeave={onHoverLeave}
|
||||
style={{
|
||||
...positionStyle,
|
||||
zIndex: 1000,
|
||||
background: '#fff',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.15)',
|
||||
padding: '12px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '6px',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600, fontSize: '14px', color: '#1e293b' }}>{name}</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{
|
||||
fontSize: '11px', padding: '2px 8px', borderRadius: '10px',
|
||||
background: style.color + '20', color: style.color, fontWeight: 600,
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{style.label}
|
||||
</span>
|
||||
<span style={{ fontSize: '12px', color: '#475569', fontVariantNumeric: 'tabular-nums' }}>
|
||||
{(allocated + external) > 0 ? (allocated + external).toFixed(1) : '0'} / 9.0 credits
|
||||
</span>
|
||||
</div>
|
||||
{(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 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>
|
||||
<span style={{ fontWeight: 600, fontVariantNumeric: 'tabular-nums' }}>{c.credits.toFixed(1)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
import { useMemo } from 'react';
|
||||
import { ELECTIVE_SETS } from '../data/electiveSets';
|
||||
import { SPECIALIZATIONS } from '../data/specializations';
|
||||
import { courseById } from '../data/lookups';
|
||||
import { specColor } from '../data/specColors';
|
||||
import { makePriorityRankWeight } from '../solver/priority';
|
||||
import type { PlanOutcome } from '../solver/decisionTree';
|
||||
|
||||
const setNameById: Record<string, string> = {};
|
||||
for (const s of ELECTIVE_SETS) setNameById[s.id] = s.name;
|
||||
|
||||
const specNameById: Record<string, string> = {};
|
||||
for (const s of SPECIALIZATIONS) specNameById[s.id] = s.name;
|
||||
|
||||
interface TopPlansProps {
|
||||
plans: PlanOutcome[];
|
||||
partial: boolean;
|
||||
loading: boolean;
|
||||
progress: { iterations: number; iterationsTotal: number } | null;
|
||||
pinnedCourses: Record<string, string | null>;
|
||||
ranking: string[];
|
||||
showAnimatedBar?: boolean;
|
||||
onAdopt: (assignments: Record<string, string>) => void;
|
||||
onPin: (setId: string, courseId: string) => void;
|
||||
onUnpin: (setId: string) => void;
|
||||
}
|
||||
|
||||
function formatNum(n: number): string {
|
||||
return n.toLocaleString();
|
||||
}
|
||||
|
||||
/** Compact form for the lex rank-weight, e.g. 16384 → "16.4k". */
|
||||
function formatScore(n: number): string {
|
||||
if (n < 1000) return String(n);
|
||||
const k = n / 1000;
|
||||
return `${k.toFixed(k >= 100 ? 0 : 1)}k`;
|
||||
}
|
||||
|
||||
export function TopPlans({ plans, partial, loading, progress, pinnedCourses, ranking, showAnimatedBar = true, onAdopt, onPin, onUnpin }: TopPlansProps) {
|
||||
const rankWeight = useMemo(() => makePriorityRankWeight(ranking), [ranking]);
|
||||
const allPinned = ELECTIVE_SETS.every((s) => pinnedCourses[s.id]);
|
||||
const visible = allPinned ? plans : plans.filter((p) => p.achievedSpecs.length > 0);
|
||||
const heading = allPinned ? 'Your Plan' : 'Top Plans';
|
||||
|
||||
const pct = progress && progress.iterationsTotal > 0
|
||||
? Math.min(100, (progress.iterations / progress.iterationsTotal) * 100)
|
||||
: 0;
|
||||
|
||||
let staticText: string | null = null;
|
||||
if (!loading && partial && progress) {
|
||||
staticText = `Search incomplete · cap hit at ${formatNum(progress.iterations)}`;
|
||||
} else if (!loading && progress) {
|
||||
staticText = `Search complete · ${formatNum(progress.iterations)} explored`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: '8px', gap: '8px', flexWrap: 'wrap' }}>
|
||||
<h3 style={{ fontSize: '14px', margin: 0, color: '#444' }}>
|
||||
{heading}
|
||||
{!allPinned && visible.length > 0 && (
|
||||
<span style={{ fontSize: '11px', color: '#888', fontWeight: 400, marginLeft: '6px' }}>
|
||||
ranked by specs achieved
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
{staticText && (
|
||||
<span style={{ fontSize: '11px', color: partial ? '#92400e' : '#888' }}>
|
||||
{staticText}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{showAnimatedBar && loading && progress && (
|
||||
<div style={{ marginBottom: '8px' }}>
|
||||
<div style={{
|
||||
position: 'relative', height: '6px', background: '#e5e7eb',
|
||||
borderRadius: '3px', overflow: 'hidden',
|
||||
}}>
|
||||
<div style={{
|
||||
position: 'absolute', top: 0, left: 0, height: '100%',
|
||||
width: `${pct}%`, background: '#3b82f6',
|
||||
transition: 'width 150ms ease-out',
|
||||
}} />
|
||||
</div>
|
||||
<div style={{
|
||||
display: 'flex', justifyContent: 'space-between',
|
||||
fontSize: '10px', color: '#888', marginTop: '2px',
|
||||
}}>
|
||||
<span>Searching…</span>
|
||||
<span>
|
||||
{formatNum(progress.iterations)} / {formatNum(progress.iterationsTotal)}
|
||||
{' · '}{Math.round(pct)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{loading && visible.length === 0 && (
|
||||
<div style={{ fontSize: '12px', color: '#888', fontStyle: 'italic' }}>
|
||||
Searching for high-priority plans…
|
||||
</div>
|
||||
)}
|
||||
{!loading && visible.length === 0 && (
|
||||
<div style={{ fontSize: '12px', color: '#888', fontStyle: 'italic' }}>
|
||||
No plans yet achieve a specialization with the current pinned courses.
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
|
||||
{visible.map((plan, i) => (
|
||||
<PlanRow
|
||||
key={i + ':' + plan.priorityScore}
|
||||
plan={plan}
|
||||
rank={i + 1}
|
||||
pinnedCourses={pinnedCourses}
|
||||
rankWeight={rankWeight}
|
||||
onAdopt={onAdopt}
|
||||
onPin={onPin}
|
||||
onUnpin={onUnpin}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PlanRow({
|
||||
plan,
|
||||
rank,
|
||||
pinnedCourses,
|
||||
rankWeight,
|
||||
onAdopt,
|
||||
onPin,
|
||||
onUnpin,
|
||||
}: {
|
||||
plan: PlanOutcome;
|
||||
rank: number;
|
||||
pinnedCourses: Record<string, string | null>;
|
||||
rankWeight: (specs: string[]) => number;
|
||||
onAdopt: (assignments: Record<string, string>) => void;
|
||||
onPin: (setId: string, courseId: string) => void;
|
||||
onUnpin: (setId: string) => void;
|
||||
}) {
|
||||
const weight = rankWeight(plan.achievedSpecs);
|
||||
// Combine the plan's open-set assignments with the user's currently-pinned
|
||||
// courses so the row shows the full sequence across all 12 sets.
|
||||
const assignmentEntries: [string, string][] = ELECTIVE_SETS
|
||||
.map((s) => {
|
||||
const pinned = pinnedCourses[s.id];
|
||||
const planned = plan.courseAssignments[s.id];
|
||||
const courseId = pinned ?? planned;
|
||||
return courseId ? ([s.id, courseId] as [string, string]) : null;
|
||||
})
|
||||
.filter((e): e is [string, string] => e !== null);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '6px',
|
||||
padding: '8px 10px',
|
||||
background: '#fff',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '6px',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', flexWrap: 'wrap' }}>
|
||||
<span style={{ fontSize: '11px', color: '#666', fontWeight: 600, minWidth: '20px' }}>
|
||||
#{rank}
|
||||
</span>
|
||||
{plan.achievedSpecs.map((specId) => {
|
||||
const c = specColor(specId);
|
||||
return (
|
||||
<span
|
||||
key={specId}
|
||||
title={specNameById[specId]}
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
fontWeight: 700,
|
||||
padding: '2px 8px',
|
||||
borderRadius: '10px',
|
||||
background: c.bg,
|
||||
color: c.fg,
|
||||
border: `1px solid ${c.border}`,
|
||||
}}
|
||||
>
|
||||
{specId}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
<span
|
||||
title={`Lexicographic priority weight: ${weight.toLocaleString()}`}
|
||||
style={{ fontSize: '10px', color: '#888', marginLeft: 'auto', fontVariantNumeric: 'tabular-nums' }}
|
||||
>
|
||||
score {formatScore(weight)}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => onAdopt(plan.courseAssignments)}
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
padding: '3px 10px',
|
||||
border: '1px solid #bfdbfe',
|
||||
background: '#eff6ff',
|
||||
color: '#2563eb',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Adopt plan
|
||||
</button>
|
||||
</div>
|
||||
<div style={{
|
||||
display: 'flex', flexWrap: 'wrap', gap: '4px',
|
||||
}}>
|
||||
{assignmentEntries.map(([setId, courseId]) => {
|
||||
const course = courseById[courseId];
|
||||
const label = setNameById[setId]?.replace('Elective Set ', 'S').replace('Spring ', 'Sp').replace('Summer ', 'Su').replace('Fall ', 'F').replace(' ', '') ?? setId;
|
||||
const isPinned = pinnedCourses[setId] === courseId;
|
||||
return (
|
||||
<button
|
||||
key={setId}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (isPinned) onUnpin(setId);
|
||||
else onPin(setId, courseId);
|
||||
}}
|
||||
title={
|
||||
isPinned
|
||||
? `Unpin "${course?.name ?? courseId}" from ${setNameById[setId]}`
|
||||
: `Pin "${course?.name ?? courseId}" in ${setNameById[setId]}`
|
||||
}
|
||||
style={{
|
||||
flex: '1 1 110px', minWidth: '90px', maxWidth: '180px',
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'flex-start',
|
||||
textAlign: 'left',
|
||||
padding: '4px 6px', gap: '2px',
|
||||
border: isPinned ? '1px solid #3b82f6' : '1px solid #e5e7eb',
|
||||
borderRadius: '4px',
|
||||
background: isPinned ? '#dbeafe' : '#f9fafb',
|
||||
cursor: 'pointer',
|
||||
font: 'inherit',
|
||||
transition: 'background 150ms, border-color 150ms',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (isPinned) {
|
||||
e.currentTarget.style.background = '#bfdbfe';
|
||||
} else {
|
||||
e.currentTarget.style.background = '#eff6ff';
|
||||
e.currentTarget.style.borderColor = '#bfdbfe';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (isPinned) {
|
||||
e.currentTarget.style.background = '#dbeafe';
|
||||
} else {
|
||||
e.currentTarget.style.background = '#f9fafb';
|
||||
e.currentTarget.style.borderColor = '#e5e7eb';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
fontSize: '9px', fontWeight: 700,
|
||||
color: isPinned ? '#1e40af' : '#94a3b8',
|
||||
letterSpacing: '0.3px', textTransform: 'uppercase',
|
||||
}}>
|
||||
{label}{isPinned && ' · pinned'}
|
||||
</span>
|
||||
<span style={{
|
||||
fontSize: '11px',
|
||||
color: isPinned ? '#1e3a8a' : '#374151',
|
||||
fontWeight: isPinned ? 600 : 500,
|
||||
lineHeight: 1.25,
|
||||
display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden', textOverflow: 'ellipsis',
|
||||
}}>
|
||||
{course?.name ?? courseId}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,16 +5,16 @@ import { SPECIALIZATIONS } from '../specializations';
|
||||
import { coursesBySet, coursesBySpec } from '../lookups';
|
||||
|
||||
describe('Data integrity', () => {
|
||||
it('has exactly 46 courses', () => {
|
||||
expect(COURSES.length).toBe(46);
|
||||
it('has exactly 47 courses', () => {
|
||||
expect(COURSES.length).toBe(47);
|
||||
});
|
||||
|
||||
it('has exactly 12 elective sets', () => {
|
||||
expect(ELECTIVE_SETS.length).toBe(12);
|
||||
});
|
||||
|
||||
it('has exactly 14 specializations', () => {
|
||||
expect(SPECIALIZATIONS.length).toBe(14);
|
||||
it('has exactly 15 specializations', () => {
|
||||
expect(SPECIALIZATIONS.length).toBe(15);
|
||||
});
|
||||
|
||||
it('every course belongs to a valid set and that set references the course', () => {
|
||||
@@ -78,30 +78,33 @@ describe('Data integrity', () => {
|
||||
});
|
||||
|
||||
describe('per-specialization "across sets" counts match reachability table', () => {
|
||||
// Expected counts: number of distinct sets that have at least one qualifying course
|
||||
// Expected counts: number of distinct sets that have at least one non-cancelled qualifying course
|
||||
const expectedAcrossSets: Record<string, number> = {
|
||||
MGT: 11,
|
||||
STR: 9,
|
||||
LCM: 9,
|
||||
FIN: 9,
|
||||
CRF: 8,
|
||||
MKT: 7,
|
||||
MKT: 6,
|
||||
BNK: 6,
|
||||
BRM: 6,
|
||||
FIM: 6,
|
||||
MTO: 6,
|
||||
BRM: 5,
|
||||
GLB: 5,
|
||||
EMT: 4,
|
||||
ENT: 4,
|
||||
HCR: 4,
|
||||
SBI: 4,
|
||||
};
|
||||
|
||||
for (const [specId, expected] of Object.entries(expectedAcrossSets)) {
|
||||
it(`${specId} qualifies across ${expected} sets`, () => {
|
||||
const entries = coursesBySpec[specId] || [];
|
||||
const courseIds = entries.map((e) => e.courseId);
|
||||
const setIds = new Set(
|
||||
courseIds.map((cid) => COURSES.find((c) => c.id === cid)!.setId)
|
||||
entries
|
||||
.map((e) => COURSES.find((c) => c.id === e.courseId)!)
|
||||
.filter((c) => !c.cancelled)
|
||||
.map((c) => c.setId)
|
||||
);
|
||||
expect(setIds.size).toBe(expected);
|
||||
});
|
||||
|
||||
@@ -114,8 +114,8 @@ export const COURSE_DESCRIPTIONS: Record<string, CourseInfo> = {
|
||||
instructors: ['Luke Williams'],
|
||||
},
|
||||
'sum2-social-media': {
|
||||
description: 'This course is designed to provide business leaders with a framework for a company to evaluate social media and enhance their integrated marketing campaigns. You will be provided with the tools to understand the current mobile technology landscape. This course covers important issues that leaders must have a POV on, including: data privacy, marketing technology, mobile video, and top mobile advertising companies. This course strikes a balanced approach of covering the pressing issues of today and timeless foundational marketing principles.\n\nThis is a fast-paced course that is designed for you to learn the basic concepts, terms, and principles that apply to the social media industry. To become familiar with key strategic issues across the sector, you will analyze the activities of the leading social media companies and applications through articles, case studies, and lectures. By the conclusion of the course, as a senior executive you will have gained an understanding of the opportunities and challenges your organization must consider as it manages its social media and mobile technology platforms.',
|
||||
instructors: ['Stewart Krentzman'],
|
||||
description: 'Digital strategy drives business performance. It shapes how companies capture demand and design customer experiences. At senior management levels today, digital fluency is expected. Business leaders know where to focus their teams, what to prioritize, and where customer understanding driven by agentic AI can create competitive advantage.\n\nThe course centers on live engagement with the senior digital strategy team at Memorial Sloan Kettering Cancer Center - one of the top two cancer centers in the country. Teams will develop and present a digital strategy that encourages patients under the age of 40 to seek their care at MSKCC.\n\nYou\'ll build a practical framework connecting segmentation, positioning, content, channels, platforms, AI, and performance measurement. Increasingly, this includes agentic AI - systems that don\'t just generate outputs, but act: orchestrating journeys, adapting content in real time. The challenge is not access to these capabilities but knowing where and how to deploy them for impact.\n\nTara Liggins, VP of Engagement at L\'Oréal, will join several sessions to show how digital strategy translates into digital tactical engagement. Dr. Nnamdi Ezeanochie, a thought leader at Google Gemini will lead a session on how all aspects of AI is reshaping strategy - from passive tools to systems that act, decide, and optimize.\n\nBy the end of this course, students will not just understand digital strategy. Importantly, they will leave with the confidence and judgment to LEAD cross-functional teams, influence executive stakeholders, and drive digital strategies that don\'t sit on slides - but get funded, implemented, and deliver results.',
|
||||
instructors: [],
|
||||
},
|
||||
'sum2-leading-ai': {
|
||||
description: 'We\'re at a new age, an age where artificial intelligence is becoming the most influential General Purpose Technology, a technology that once arrived, is poised to morph all aspects of our lives, irreversibly. Artificial Intelligence (AI) rapidly moves into the mainstream, supported by emerging capabilities in cloud and quantum computing, big data, open source software, and ML algorithms to name a few key forces. AI is already demonstrating capabilities that generate greater efficiencies, precision, and personalization, and at times, greater creative output than humans. And with this growing capacity, there grow questions regarding the business value of AI, the societal implications of deploying this technology, and of course, new and intriguing ethical considerations. This course will introduce you to some of the major disruptive Artificial Intelligence developments, concepts, and considerations, and will address the future of work questions as we lead and evolve/sustain AI-enabled businesses.',
|
||||
|
||||
+11
-5
@@ -22,7 +22,7 @@ export const COURSES: Course[] = [
|
||||
},
|
||||
{
|
||||
id: 'spr2-health-medical', name: 'The Business of Health & Medical Care', setId: 'spr2',
|
||||
qualifications: [{ specId: 'STR', marker: 'S2' }],
|
||||
qualifications: [{ specId: 'HCR', marker: 'standard' }, { specId: 'STR', marker: 'S2' }],
|
||||
},
|
||||
{
|
||||
id: 'spr2-human-rights', name: 'Human Rights and Business', setId: 'spr2',
|
||||
@@ -54,7 +54,7 @@ export const COURSES: Course[] = [
|
||||
},
|
||||
{
|
||||
id: 'spr3-analytics-ml', name: 'Analytics & Machine Learning for Managers', setId: 'spr3',
|
||||
qualifications: [{ specId: 'MTO', marker: 'standard' }],
|
||||
qualifications: [{ specId: 'HCR', marker: 'standard' }, { specId: 'MTO', marker: 'standard' }],
|
||||
},
|
||||
|
||||
// === Spring Elective Set 4 ===
|
||||
@@ -91,6 +91,7 @@ export const COURSES: Course[] = [
|
||||
{
|
||||
id: 'spr5-customer-insights', name: 'Customer Insights', setId: 'spr5',
|
||||
qualifications: [{ specId: 'BRM', marker: 'standard' }, { specId: 'MKT', marker: 'standard' }],
|
||||
cancelled: true,
|
||||
},
|
||||
|
||||
// === Summer Elective Set 1 ===
|
||||
@@ -108,6 +109,11 @@ export const COURSES: Course[] = [
|
||||
},
|
||||
|
||||
// === Summer Elective Set 2 ===
|
||||
{
|
||||
id: 'sum2-managing-growing-companies', name: 'Managing Growing Companies', setId: 'sum2',
|
||||
qualifications: [],
|
||||
cancelled: true,
|
||||
},
|
||||
{
|
||||
id: 'sum2-innovation-design', name: 'Innovation and Design', setId: 'sum2',
|
||||
qualifications: [
|
||||
@@ -116,8 +122,8 @@ export const COURSES: Course[] = [
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'sum2-social-media', name: 'Social Media and Mobile Technology', setId: 'sum2',
|
||||
qualifications: [{ specId: 'BRM', marker: 'standard' }, { specId: 'EMT', marker: 'standard' }, { specId: 'MKT', marker: 'standard' }],
|
||||
id: 'sum2-social-media', name: 'Digital Marketing Strategy in Practice', setId: 'sum2',
|
||||
qualifications: [{ specId: 'BRM', marker: 'standard' }, { specId: 'EMT', marker: 'standard' }, { specId: 'HCR', marker: 'standard' }, { specId: 'MKT', marker: 'standard' }],
|
||||
},
|
||||
{
|
||||
id: 'sum2-leading-ai', name: 'Leading in the Age of AI', setId: 'sum2',
|
||||
@@ -164,7 +170,7 @@ export const COURSES: Course[] = [
|
||||
},
|
||||
{
|
||||
id: 'fall1-managing-change', name: 'Managing Change', setId: 'fall1',
|
||||
qualifications: [{ specId: 'LCM', marker: 'standard' }, { specId: 'MGT', marker: 'standard' }, { specId: 'STR', marker: 'S2' }],
|
||||
qualifications: [{ specId: 'HCR', marker: 'standard' }, { specId: 'LCM', marker: 'standard' }, { specId: 'MGT', marker: 'standard' }, { specId: 'STR', marker: 'S2' }],
|
||||
},
|
||||
{
|
||||
id: 'fall1-social-entrepreneurship', name: 'Social Entrepreneurship', setId: 'fall1',
|
||||
|
||||
@@ -27,7 +27,7 @@ export const ELECTIVE_SETS: ElectiveSet[] = [
|
||||
},
|
||||
{
|
||||
id: 'sum2', name: 'Summer Elective Set 2', term: 'Summer',
|
||||
courseIds: ['sum2-innovation-design', 'sum2-social-media', 'sum2-leading-ai', 'sum2-business-drivers'],
|
||||
courseIds: ['sum2-managing-growing-companies', 'sum2-innovation-design', 'sum2-social-media', 'sum2-leading-ai', 'sum2-business-drivers'],
|
||||
},
|
||||
{
|
||||
id: 'sum3', name: 'Summer Elective Set 3', term: 'Summer',
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
export interface SpecColor {
|
||||
bg: string;
|
||||
fg: string;
|
||||
border: string;
|
||||
}
|
||||
|
||||
export const SPEC_COLORS: Record<string, SpecColor> = {
|
||||
BNK: { bg: '#dbeafe', fg: '#1d4ed8', border: '#bfdbfe' },
|
||||
BRM: { bg: '#fce7f3', fg: '#be185d', border: '#fbcfe8' },
|
||||
CRF: { bg: '#cffafe', fg: '#0e7490', border: '#a5f3fc' },
|
||||
EMT: { bg: '#ffedd5', fg: '#c2410c', border: '#fed7aa' },
|
||||
ENT: { bg: '#fef9c3', fg: '#a16207', border: '#fef08a' },
|
||||
FIN: { bg: '#e0e7ff', fg: '#3730a3', border: '#c7d2fe' },
|
||||
FIM: { bg: '#ccfbf1', fg: '#0f766e', border: '#99f6e4' },
|
||||
GLB: { bg: '#ecfccb', fg: '#4d7c0f', border: '#d9f99d' },
|
||||
HCR: { bg: '#d1fae5', fg: '#047857', border: '#a7f3d0' },
|
||||
LCM: { bg: '#ede9fe', fg: '#6d28d9', border: '#ddd6fe' },
|
||||
MGT: { bg: '#f3e8ff', fg: '#7e22ce', border: '#e9d5ff' },
|
||||
MKT: { bg: '#ffe4e6', fg: '#be123c', border: '#fecdd3' },
|
||||
MTO: { bg: '#fed7aa', fg: '#9a3412', border: '#fdba74' },
|
||||
SBI: { bg: '#bbf7d0', fg: '#166534', border: '#86efac' },
|
||||
STR: { bg: '#e0e7ff', fg: '#4338ca', border: '#a5b4fc' },
|
||||
};
|
||||
|
||||
const FALLBACK: SpecColor = { bg: '#f3f4f6', fg: '#374151', border: '#e5e7eb' };
|
||||
|
||||
export function specColor(specId: string): SpecColor {
|
||||
return SPEC_COLORS[specId] ?? FALLBACK;
|
||||
}
|
||||
@@ -9,6 +9,7 @@ export const SPECIALIZATIONS: Specialization[] = [
|
||||
{ id: 'FIN', name: 'Finance', abbreviation: 'FIN' },
|
||||
{ id: 'FIM', name: 'Financial Instruments and Markets', abbreviation: 'FIM' },
|
||||
{ id: 'GLB', name: 'Global Business', abbreviation: 'GLB' },
|
||||
{ id: 'HCR', name: 'Healthcare', abbreviation: 'HCR' },
|
||||
{ id: 'LCM', name: 'Leadership and Change Management', abbreviation: 'LCM' },
|
||||
{ id: 'MGT', name: 'Management', abbreviation: 'MGT' },
|
||||
{ id: 'MKT', name: 'Marketing', abbreviation: 'MKT' },
|
||||
|
||||
@@ -64,7 +64,10 @@ describe('analyzeDecisionTree', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('calls onSetComplete progressively', () => {
|
||||
it('invokes onSetComplete (per-cell streaming) for every open set', () => {
|
||||
// After the streaming refactor, the legacy onSetComplete callback fires
|
||||
// per cell update rather than once per set. Assert that every open set's
|
||||
// analysis is delivered at least once.
|
||||
const pinned = [
|
||||
'spr1-collaboration',
|
||||
'spr2-financial-services',
|
||||
@@ -78,14 +81,14 @@ describe('analyzeDecisionTree', () => {
|
||||
'fall2-behavioral-finance',
|
||||
];
|
||||
const openSets = ['fall3', 'fall4'];
|
||||
const completed: string[] = [];
|
||||
const completed = new Set<string>();
|
||||
|
||||
analyzeDecisionTree(pinned, openSets, allSpecIds, 'maximize-count', (analysis) => {
|
||||
completed.push(analysis.setId);
|
||||
completed.add(analysis.setId);
|
||||
});
|
||||
|
||||
expect(completed).toContain('fall3');
|
||||
expect(completed).toContain('fall4');
|
||||
expect(completed.length).toBe(2);
|
||||
expect(completed.has('fall3')).toBe(true);
|
||||
expect(completed.has('fall4')).toBe(true);
|
||||
expect(completed.size).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
searchDecisionTree,
|
||||
deriveFromLeaves,
|
||||
assignmentKey,
|
||||
type PlanOutcome,
|
||||
} from '../decisionTree';
|
||||
import { COURSES } from '../../data/courses';
|
||||
import { SPECIALIZATIONS } from '../../data/specializations';
|
||||
|
||||
const cancelledIds = new Set(COURSES.filter((c) => c.cancelled).map((c) => c.id));
|
||||
const allSpecIds = SPECIALIZATIONS.map((s) => s.id);
|
||||
|
||||
describe('leaf cache: skipKeys parity', () => {
|
||||
it('two-pass run with skipKeys produces identical final result', () => {
|
||||
const PINNED = [
|
||||
'spr1-collaboration',
|
||||
'spr2-financial-services',
|
||||
'spr3-mergers-acquisitions',
|
||||
'spr4-fintech',
|
||||
'spr5-corporate-finance',
|
||||
'sum1-collaboration',
|
||||
'sum2-innovation-design',
|
||||
'sum3-valuation',
|
||||
'fall1-private-equity',
|
||||
'fall2-behavioral-finance',
|
||||
];
|
||||
const OPEN = ['fall3', 'fall4'];
|
||||
const pinnedAssignments: Record<string, string> = {};
|
||||
for (const id of PINNED) {
|
||||
const c = COURSES.find((x) => x.id === id)!;
|
||||
pinnedAssignments[c.setId] = id;
|
||||
}
|
||||
|
||||
// Pass 1: full run, capture all leaves
|
||||
const leaves: PlanOutcome[] = [];
|
||||
const r1 = searchDecisionTree(
|
||||
PINNED, OPEN, allSpecIds, 'maximize-count', 10,
|
||||
{ onLeafEvaluated: (l) => leaves.push(l) },
|
||||
cancelledIds, undefined, pinnedAssignments,
|
||||
);
|
||||
|
||||
// Pass 2: skip every leaf the first run produced
|
||||
const skipKeys = new Set(leaves.map((l) => assignmentKey(l.courseAssignments)));
|
||||
let evaluatedCount = 0;
|
||||
const r2 = searchDecisionTree(
|
||||
PINNED, OPEN, allSpecIds, 'maximize-count', 10,
|
||||
{ onLeafEvaluated: () => { evaluatedCount++; } },
|
||||
cancelledIds, skipKeys, pinnedAssignments,
|
||||
);
|
||||
|
||||
// r2 should have visited all leaves but evaluated none
|
||||
expect(r2.iterations).toBe(r1.iterations);
|
||||
expect(r2.iterationsTotal).toBe(r1.iterationsTotal);
|
||||
expect(evaluatedCount).toBe(0);
|
||||
// r2's topK is empty since nothing was evaluated; this is expected
|
||||
// (cache provides the data on the main thread)
|
||||
expect(r2.topK.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deriveFromLeaves parity', () => {
|
||||
it('matches a fresh search when given the same leaves', () => {
|
||||
const PINNED = [
|
||||
'spr1-collaboration', 'spr2-financial-services', 'spr3-mergers-acquisitions',
|
||||
'spr4-fintech', 'spr5-corporate-finance', 'sum1-collaboration',
|
||||
'sum2-innovation-design', 'sum3-valuation', 'fall1-private-equity', 'fall2-behavioral-finance',
|
||||
];
|
||||
const OPEN = ['fall3', 'fall4'];
|
||||
const pinnedAssignments: Record<string, string> = {};
|
||||
for (const id of PINNED) {
|
||||
const c = COURSES.find((x) => x.id === id)!;
|
||||
pinnedAssignments[c.setId] = id;
|
||||
}
|
||||
|
||||
const leaves: PlanOutcome[] = [];
|
||||
const search = searchDecisionTree(
|
||||
PINNED, OPEN, allSpecIds, 'maximize-count', 10,
|
||||
{ onLeafEvaluated: (l) => leaves.push(l) },
|
||||
cancelledIds, undefined, pinnedAssignments,
|
||||
);
|
||||
|
||||
const derived = deriveFromLeaves(leaves, 10, 'maximize-count', allSpecIds, OPEN, cancelledIds);
|
||||
|
||||
// Top-K matches in length and outcomes
|
||||
expect(derived.topK.length).toBe(search.topK.length);
|
||||
for (let i = 0; i < search.topK.length; i++) {
|
||||
expect(derived.topK[i].achievedSpecs).toEqual(search.topK[i].achievedSpecs);
|
||||
expect(derived.topK[i].priorityScore).toBe(search.topK[i].priorityScore);
|
||||
}
|
||||
|
||||
// Per-set analyses match
|
||||
for (const setAnalysis of search.setAnalyses) {
|
||||
const dSet = derived.setAnalyses.find((s) => s.setId === setAnalysis.setId)!;
|
||||
for (const choice of setAnalysis.choices) {
|
||||
const dChoice = dSet.choices.find((c) => c.courseId === choice.courseId)!;
|
||||
expect(dChoice.ceilingCount).toBe(choice.ceilingCount);
|
||||
expect(dChoice.ceilingSpecs).toEqual(choice.ceilingSpecs);
|
||||
expect(dChoice.evaluated).toBe(choice.evaluated);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('cache filter semantics', () => {
|
||||
it('filtering retains only leaves matching pinned assignments', () => {
|
||||
// Build a small synthetic set of leaves
|
||||
const leaves: PlanOutcome[] = [
|
||||
{ courseAssignments: { spr3: 'spr3-mergers-acquisitions', fall3: 'fall3-climate-finance' }, achievedSpecs: ['BNK'], priorityScore: 14 },
|
||||
{ courseAssignments: { spr3: 'spr3-analytics-ml', fall3: 'fall3-climate-finance' }, achievedSpecs: ['HCR'], priorityScore: 15 },
|
||||
{ courseAssignments: { spr3: 'spr3-mergers-acquisitions', fall3: 'fall3-corporate-governance' }, achievedSpecs: ['LCM'], priorityScore: 6 },
|
||||
];
|
||||
// Filter for spr3 = analytics-ml
|
||||
const pinned = { spr3: 'spr3-analytics-ml' };
|
||||
const filtered = leaves.filter((l) =>
|
||||
Object.entries(pinned).every(([s, c]) => l.courseAssignments[s] === c),
|
||||
);
|
||||
expect(filtered.length).toBe(1);
|
||||
expect(filtered[0].achievedSpecs).toEqual(['HCR']);
|
||||
});
|
||||
|
||||
it('filtering drops leaves containing excluded courses', () => {
|
||||
const leaves: PlanOutcome[] = [
|
||||
{ courseAssignments: { spr1: 'spr1-global-immersion', sum1: 'sum1-global-immersion' }, achievedSpecs: [], priorityScore: 0 },
|
||||
{ courseAssignments: { spr1: 'spr1-collaboration', sum1: 'sum1-high-stakes' }, achievedSpecs: ['LCM'], priorityScore: 6 },
|
||||
];
|
||||
const excluded = new Set(['sum1-global-immersion']);
|
||||
const filtered = leaves.filter((l) =>
|
||||
!Object.values(l.courseAssignments).some((cid) => excluded.has(cid)),
|
||||
);
|
||||
expect(filtered.length).toBe(1);
|
||||
expect(filtered[0].achievedSpecs).toEqual(['LCM']);
|
||||
});
|
||||
});
|
||||
@@ -149,7 +149,7 @@ describe('optimize (integration)', () => {
|
||||
expect(result.allocations).toBeDefined();
|
||||
expect(result.statuses).toBeDefined();
|
||||
expect(result.upperBounds).toBeDefined();
|
||||
expect(Object.keys(result.statuses).length).toBe(14);
|
||||
expect(Object.keys(result.statuses).length).toBe(SPECIALIZATIONS.length);
|
||||
});
|
||||
|
||||
it('modes can produce different results', () => {
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
searchDecisionTree,
|
||||
BoundedRankedList,
|
||||
compareOutcomes,
|
||||
selectPriorityTarget,
|
||||
reorderForTarget,
|
||||
reorderByReachableQualCount,
|
||||
PROGRESS_THROTTLE_MS,
|
||||
type PlanOutcome,
|
||||
} from '../decisionTree';
|
||||
import { computeUpperBounds } from '../feasibility';
|
||||
import { SPECIALIZATIONS } from '../../data/specializations';
|
||||
import { COURSES } from '../../data/courses';
|
||||
import { ELECTIVE_SETS } from '../../data/electiveSets';
|
||||
import { priorityScore } from '../priority';
|
||||
|
||||
const cancelledIds = new Set(COURSES.filter((c) => c.cancelled).map((c) => c.id));
|
||||
const allSpecIds = SPECIALIZATIONS.map((s) => s.id);
|
||||
|
||||
describe('priorityScore', () => {
|
||||
it('weights specs by their position in ranking', () => {
|
||||
const ranking = ['HCR', 'BNK', 'FIN'];
|
||||
expect(priorityScore(['HCR'], ranking)).toBe(15);
|
||||
expect(priorityScore(['BNK'], ranking)).toBe(14);
|
||||
expect(priorityScore(['FIN'], ranking)).toBe(13);
|
||||
expect(priorityScore(['HCR', 'BNK'], ranking)).toBe(29);
|
||||
expect(priorityScore([], ranking)).toBe(0);
|
||||
});
|
||||
|
||||
it('falls back to lowest weight for unranked specs', () => {
|
||||
const ranking = ['HCR'];
|
||||
// FALLBACK_RANK = 14 → weight = 15 - 14 = 1
|
||||
expect(priorityScore(['STR'], ranking)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('BoundedRankedList', () => {
|
||||
const cmp = (a: number, b: number) => b - a; // sort descending
|
||||
|
||||
it('inserts items maintaining descending order', () => {
|
||||
const list = new BoundedRankedList<number>(5, cmp);
|
||||
expect(list.tryInsert(3)).toBe(true);
|
||||
expect(list.tryInsert(7)).toBe(true);
|
||||
expect(list.tryInsert(5)).toBe(true);
|
||||
expect(list.toArray()).toEqual([7, 5, 3]);
|
||||
});
|
||||
|
||||
it('drops worst entry when over capacity', () => {
|
||||
const list = new BoundedRankedList<number>(3, cmp);
|
||||
[10, 5, 8, 1, 12].forEach((v) => list.tryInsert(v));
|
||||
expect(list.toArray()).toEqual([12, 10, 8]);
|
||||
});
|
||||
|
||||
it('rejects items that cannot enter at capacity', () => {
|
||||
const list = new BoundedRankedList<number>(2, cmp);
|
||||
list.tryInsert(10);
|
||||
list.tryInsert(5);
|
||||
expect(list.tryInsert(1)).toBe(false);
|
||||
expect(list.toArray()).toEqual([10, 5]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('priorityRankWeight (lex compare)', () => {
|
||||
it('a single top-ranked spec outweighs any combination of lower-ranked specs', async () => {
|
||||
const { priorityRankWeight } = await import('../priority');
|
||||
const ranking = ['HCR', 'BNK', 'BRM', 'CRF', 'EMT', 'ENT', 'FIN', 'FIM', 'GLB', 'LCM', 'MGT', 'MKT', 'MTO', 'SBI', 'STR'];
|
||||
const justHCR = priorityRankWeight(['HCR'], ranking);
|
||||
const allOthers = priorityRankWeight(['BNK', 'BRM', 'CRF', 'EMT', 'ENT', 'FIN', 'FIM', 'GLB', 'LCM', 'MGT', 'MKT', 'MTO', 'SBI', 'STR'], ranking);
|
||||
expect(justHCR).toBeGreaterThan(allOthers);
|
||||
});
|
||||
|
||||
it('among plans containing the top spec, the next-ranked spec is the tiebreaker', async () => {
|
||||
const { priorityRankWeight } = await import('../priority');
|
||||
const ranking = ['HCR', 'BNK', 'CRF'];
|
||||
const hcrBnk = priorityRankWeight(['HCR', 'BNK'], ranking);
|
||||
const hcrCrf = priorityRankWeight(['HCR', 'CRF'], ranking);
|
||||
expect(hcrBnk).toBeGreaterThan(hcrCrf);
|
||||
});
|
||||
});
|
||||
|
||||
describe('compareOutcomes', () => {
|
||||
const make = (specs: string[], score: number, key: string): PlanOutcome => ({
|
||||
courseAssignments: { spr1: key },
|
||||
achievedSpecs: specs,
|
||||
priorityScore: score,
|
||||
});
|
||||
|
||||
it('higher count beats lower count regardless of priority', () => {
|
||||
const a = make(['BNK', 'FIN', 'CRF'], 5, 'a');
|
||||
const b = make(['HCR'], 100, 'b');
|
||||
expect(compareOutcomes(a, b)).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it('equal count → higher priority score wins', () => {
|
||||
const a = make(['HCR', 'BNK'], 29, 'a');
|
||||
const b = make(['FIN', 'MTO'], 22, 'b');
|
||||
expect(compareOutcomes(a, b)).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it('equal count and score → deterministic tiebreak by assignment key', () => {
|
||||
const a = make(['HCR'], 15, 'aaa');
|
||||
const b = make(['HCR'], 15, 'bbb');
|
||||
// a has lex-smaller key, so a wins (returns negative)
|
||||
expect(compareOutcomes(a, b)).toBeLessThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectPriorityTarget / reorderForTarget', () => {
|
||||
it('returns first reachable spec in ranking', () => {
|
||||
const upper = { HCR: 5, BNK: 12, FIN: 20 };
|
||||
expect(selectPriorityTarget(['HCR', 'BNK', 'FIN'], upper)).toBe('BNK');
|
||||
});
|
||||
|
||||
it('returns null when no spec is reachable', () => {
|
||||
const upper = { HCR: 5, BNK: 7 };
|
||||
expect(selectPriorityTarget(['HCR', 'BNK'], upper)).toBe(null);
|
||||
});
|
||||
|
||||
it('reorders set children with target-qualifying first', () => {
|
||||
// spr3 contains analytics-ml (HCR), mergers-acq (CRF/FIN/LCM/STR-S1), etc.
|
||||
const reordered = reorderForTarget('spr3', 'HCR', cancelledIds);
|
||||
expect(reordered[0].id).toBe('spr3-analytics-ml');
|
||||
});
|
||||
|
||||
it('returns courses unchanged when target is null', () => {
|
||||
const original = reorderForTarget('spr3', null, cancelledIds);
|
||||
expect(original.map((c) => c.id)).toEqual([
|
||||
'spr3-mergers-acquisitions',
|
||||
'spr3-digital-strategy',
|
||||
'spr3-managing-high-tech',
|
||||
'spr3-analytics-ml',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchDecisionTree — HCR reproduction scenario', () => {
|
||||
const PINNED = [
|
||||
'spr2-health-medical',
|
||||
'spr4-fintech',
|
||||
'spr5-corporate-finance',
|
||||
'sum1-global-immersion',
|
||||
];
|
||||
const OPEN_SETS = ELECTIVE_SETS
|
||||
.map((s) => s.id)
|
||||
.filter(
|
||||
(id) =>
|
||||
!PINNED.some(
|
||||
(p) => COURSES.find((c) => c.id === p)?.setId === id,
|
||||
),
|
||||
);
|
||||
const RANKING = ['HCR', ...allSpecIds.filter((id) => id !== 'HCR')];
|
||||
|
||||
it('topK[0] achieves HCR when HCR is ranked first', () => {
|
||||
const result = searchDecisionTree(
|
||||
PINNED,
|
||||
OPEN_SETS,
|
||||
RANKING,
|
||||
'priority-order',
|
||||
10,
|
||||
undefined,
|
||||
cancelledIds,
|
||||
);
|
||||
expect(result.topK.length).toBeGreaterThan(0);
|
||||
expect(result.topK[0].achievedSpecs).toContain('HCR');
|
||||
}, 30_000);
|
||||
|
||||
it('per-set ceiling for spr3-analytics-ml includes HCR', () => {
|
||||
const result = searchDecisionTree(
|
||||
PINNED,
|
||||
OPEN_SETS,
|
||||
RANKING,
|
||||
'priority-order',
|
||||
10,
|
||||
undefined,
|
||||
cancelledIds,
|
||||
);
|
||||
const spr3 = result.setAnalyses.find((a) => a.setId === 'spr3');
|
||||
const aml = spr3?.choices.find((c) => c.courseId === 'spr3-analytics-ml');
|
||||
expect(aml?.ceilingSpecs).toContain('HCR');
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
describe('searchDecisionTree — ordering and streaming', () => {
|
||||
it('streamed topK is monotonically improving', () => {
|
||||
const PINNED = [
|
||||
'spr2-health-medical',
|
||||
'spr4-fintech',
|
||||
'spr5-corporate-finance',
|
||||
'sum1-global-immersion',
|
||||
];
|
||||
const OPEN_SETS = ['spr1', 'spr3', 'sum2', 'sum3', 'fall1', 'fall2', 'fall3', 'fall4'];
|
||||
const RANKING = ['HCR', ...allSpecIds.filter((id) => id !== 'HCR')];
|
||||
|
||||
const snapshots: PlanOutcome[][] = [];
|
||||
searchDecisionTree(
|
||||
PINNED,
|
||||
OPEN_SETS,
|
||||
RANKING,
|
||||
'priority-order',
|
||||
10,
|
||||
{
|
||||
onTopKUpdate: (topK) => {
|
||||
snapshots.push(topK);
|
||||
},
|
||||
},
|
||||
cancelledIds,
|
||||
);
|
||||
|
||||
expect(snapshots.length).toBeGreaterThan(0);
|
||||
// Each snapshot's [0] is no worse than predecessor's [0]
|
||||
for (let i = 1; i < snapshots.length; i++) {
|
||||
const prev = snapshots[i - 1][0];
|
||||
const curr = snapshots[i][0];
|
||||
expect(compareOutcomes(curr, prev)).toBeLessThanOrEqual(0);
|
||||
}
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
describe('searchDecisionTree — exhaustive termination', () => {
|
||||
it('exhausts the cartesian product when within the cap', () => {
|
||||
// 2 open sets, ~16 leaves total
|
||||
const PINNED = [
|
||||
'spr1-collaboration',
|
||||
'spr2-financial-services',
|
||||
'spr3-mergers-acquisitions',
|
||||
'spr4-fintech',
|
||||
'spr5-corporate-finance',
|
||||
'sum1-collaboration',
|
||||
'sum2-innovation-design',
|
||||
'sum3-valuation',
|
||||
'fall1-private-equity',
|
||||
'fall2-behavioral-finance',
|
||||
];
|
||||
const OPEN_SETS = ['fall3', 'fall4'];
|
||||
const result = searchDecisionTree(
|
||||
PINNED,
|
||||
OPEN_SETS,
|
||||
allSpecIds,
|
||||
'maximize-count',
|
||||
10,
|
||||
undefined,
|
||||
cancelledIds,
|
||||
);
|
||||
expect(result.partial).toBe(false);
|
||||
expect(result.iterations).toBe(result.iterationsTotal);
|
||||
// Every cell in every set is evaluated after exhaustion
|
||||
for (const setAnalysis of result.setAnalyses) {
|
||||
for (const choice of setAnalysis.choices) {
|
||||
expect(choice.evaluated).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchDecisionTree — mode-dependent ordering', () => {
|
||||
it('reorderByReachableQualCount puts generalist courses first when many specs are reachable', () => {
|
||||
// All sets open so most specs are reachable; gives the heuristic something to weight against
|
||||
const allSets = ELECTIVE_SETS.map((s) => s.id);
|
||||
const upper = computeUpperBounds([], allSets, cancelledIds);
|
||||
const reordered = reorderByReachableQualCount('fall3', upper, cancelledIds);
|
||||
// Climate Finance (BNK,CRF,FIN,FIM,GLB,SBI = 6 quals) should beat
|
||||
// Corporate Governance (LCM,MGT,SBI,STR-S1 = 4 quals)
|
||||
const climateIdx = reordered.findIndex((c) => c.id === 'fall3-climate-finance');
|
||||
const corpGovIdx = reordered.findIndex((c) => c.id === 'fall3-corporate-governance');
|
||||
expect(climateIdx).toBeLessThan(corpGovIdx);
|
||||
});
|
||||
|
||||
it('maximize-count first leaf uses the generalist-ordered choices', () => {
|
||||
// Capture the first leaf evaluated in maximize-count mode by inspecting
|
||||
// the first onChoiceUpdate event for each set
|
||||
const PINNED = [
|
||||
'spr1-collaboration',
|
||||
'spr2-financial-services',
|
||||
'spr3-mergers-acquisitions',
|
||||
'spr4-fintech',
|
||||
'spr5-corporate-finance',
|
||||
'sum1-collaboration',
|
||||
'sum2-innovation-design',
|
||||
'sum3-valuation',
|
||||
'fall1-private-equity',
|
||||
'fall2-behavioral-finance',
|
||||
];
|
||||
const OPEN_SETS = ['fall3', 'fall4'];
|
||||
const firstChoiceBySet: Record<string, string> = {};
|
||||
searchDecisionTree(
|
||||
PINNED, OPEN_SETS, allSpecIds, 'maximize-count', 10,
|
||||
{
|
||||
onChoiceUpdate: (setId, analysis) => {
|
||||
if (!(setId in firstChoiceBySet)) {
|
||||
// The first cell flipped to evaluated within this update is the
|
||||
// course we're after — but the analysis sends the whole choices
|
||||
// array. Find the first evaluated course in declaration order.
|
||||
const firstEval = analysis.choices.find((c) => c.evaluated);
|
||||
if (firstEval) firstChoiceBySet[setId] = firstEval.courseId;
|
||||
}
|
||||
},
|
||||
},
|
||||
cancelledIds,
|
||||
);
|
||||
// For fall3 in max-count mode: climate-finance (most generalist) should be the first
|
||||
expect(firstChoiceBySet['fall3']).toBe('fall3-climate-finance');
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchDecisionTree — evaluated flag transitions', () => {
|
||||
it('cells start unevaluated and flip true after first leaf containing them', () => {
|
||||
const PINNED = [
|
||||
'spr1-collaboration', 'spr2-financial-services', 'spr3-mergers-acquisitions',
|
||||
'spr4-fintech', 'spr5-corporate-finance', 'sum1-collaboration',
|
||||
'sum2-innovation-design', 'sum3-valuation', 'fall1-private-equity', 'fall2-behavioral-finance',
|
||||
];
|
||||
const OPEN_SETS = ['fall3', 'fall4'];
|
||||
const result = searchDecisionTree(
|
||||
PINNED, OPEN_SETS, allSpecIds, 'maximize-count', 10, undefined, cancelledIds,
|
||||
);
|
||||
for (const sa of result.setAnalyses) {
|
||||
for (const choice of sa.choices) {
|
||||
expect(choice.evaluated).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchDecisionTree — progress events', () => {
|
||||
it('emits progress events throttled to PROGRESS_THROTTLE_MS', () => {
|
||||
const PINNED = [
|
||||
'spr2-health-medical', 'spr4-fintech', 'spr5-corporate-finance', 'sum1-global-immersion',
|
||||
];
|
||||
const OPEN_SETS = ['spr1', 'spr3', 'sum2', 'sum3', 'fall1', 'fall2', 'fall3', 'fall4'];
|
||||
const RANKING = ['HCR', ...allSpecIds.filter((id) => id !== 'HCR')];
|
||||
const timestamps: number[] = [];
|
||||
searchDecisionTree(
|
||||
PINNED, OPEN_SETS, RANKING, 'priority-order', 10,
|
||||
{ onProgress: () => timestamps.push(Date.now()) },
|
||||
cancelledIds,
|
||||
);
|
||||
// At least one progress emit (the final one always fires)
|
||||
expect(timestamps.length).toBeGreaterThan(0);
|
||||
// Consecutive emits respect the throttle (allow 5ms jitter)
|
||||
for (let i = 1; i < timestamps.length - 1; i++) {
|
||||
const delta = timestamps[i] - timestamps[i - 1];
|
||||
expect(delta).toBeGreaterThanOrEqual(PROGRESS_THROTTLE_MS - 5);
|
||||
}
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
describe('searchDecisionTree — performance smoke (exhaustive)', () => {
|
||||
it('user scenario completes in under 60s for K=10', () => {
|
||||
const PINNED = [
|
||||
'spr2-health-medical', 'spr4-fintech', 'spr5-corporate-finance', 'sum1-global-immersion',
|
||||
];
|
||||
const OPEN_SETS = ['spr1', 'spr3', 'sum2', 'sum3', 'fall1', 'fall2', 'fall3', 'fall4'];
|
||||
const RANKING = ['HCR', ...allSpecIds.filter((id) => id !== 'HCR')];
|
||||
const start = Date.now();
|
||||
const result = searchDecisionTree(
|
||||
PINNED, OPEN_SETS, RANKING, 'priority-order', 10, undefined, cancelledIds,
|
||||
);
|
||||
const elapsed = Date.now() - start;
|
||||
expect(elapsed).toBeLessThan(60_000);
|
||||
expect(result.partial).toBe(false);
|
||||
expect(result.iterations).toBe(result.iterationsTotal);
|
||||
}, 90_000);
|
||||
});
|
||||
+452
-105
@@ -1,91 +1,469 @@
|
||||
import { ELECTIVE_SETS } from '../data/electiveSets';
|
||||
import { coursesBySet } from '../data/lookups';
|
||||
import type { OptimizationMode } from '../data/types';
|
||||
import type { Course, OptimizationMode } from '../data/types';
|
||||
import { maximizeCount, priorityOrder } from './optimizer';
|
||||
import { computeUpperBounds } from './feasibility';
|
||||
import { makePriorityScorer, makePriorityRankWeight } from './priority';
|
||||
|
||||
export interface ChoiceOutcome {
|
||||
courseId: string;
|
||||
courseName: string;
|
||||
ceilingCount: number;
|
||||
ceilingSpecs: string[];
|
||||
evaluated: boolean;
|
||||
}
|
||||
|
||||
export interface SetAnalysis {
|
||||
setId: string;
|
||||
setName: string;
|
||||
impact: number; // variance in ceiling outcomes
|
||||
impact: number;
|
||||
choices: ChoiceOutcome[];
|
||||
}
|
||||
|
||||
const MAX_OPEN_SETS_FOR_ENUMERATION = 9;
|
||||
|
||||
/**
|
||||
* Compute the ceiling outcome for a single course choice:
|
||||
* the best achievable result assuming that course is pinned
|
||||
* and all other open sets are chosen optimally.
|
||||
*/
|
||||
function computeCeiling(
|
||||
basePinnedCourses: string[],
|
||||
chosenCourseId: string,
|
||||
otherOpenSetIds: string[],
|
||||
ranking: string[],
|
||||
mode: OptimizationMode,
|
||||
excludedCourseIds?: Set<string>,
|
||||
): { count: number; specs: string[] } {
|
||||
const fn = mode === 'maximize-count' ? maximizeCount : priorityOrder;
|
||||
|
||||
if (otherOpenSetIds.length === 0) {
|
||||
// No other open sets — just solve with this choice added
|
||||
const selected = [...basePinnedCourses, chosenCourseId];
|
||||
const result = fn(selected, ranking, [], excludedCourseIds);
|
||||
return { count: result.achieved.length, specs: result.achieved };
|
||||
}
|
||||
|
||||
// Enumerate all combinations of remaining open sets
|
||||
let bestCount = 0;
|
||||
let bestSpecs: string[] = [];
|
||||
|
||||
function enumerate(setIndex: number, accumulated: string[]) {
|
||||
// Early termination: already found max (3)
|
||||
if (bestCount >= 3) return;
|
||||
|
||||
if (setIndex >= otherOpenSetIds.length) {
|
||||
const selected = [...basePinnedCourses, chosenCourseId, ...accumulated];
|
||||
const result = fn(selected, ranking, [], excludedCourseIds);
|
||||
if (result.achieved.length > bestCount) {
|
||||
bestCount = result.achieved.length;
|
||||
bestSpecs = result.achieved;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const setId = otherOpenSetIds[setIndex];
|
||||
const courses = coursesBySet[setId];
|
||||
for (const course of courses) {
|
||||
if (excludedCourseIds?.has(course.id)) continue;
|
||||
enumerate(setIndex + 1, [...accumulated, course.id]);
|
||||
if (bestCount >= 3) return;
|
||||
}
|
||||
}
|
||||
|
||||
enumerate(0, []);
|
||||
return { count: bestCount, specs: bestSpecs };
|
||||
export interface PlanOutcome {
|
||||
courseAssignments: Record<string, string>; // setId -> courseId for open sets
|
||||
achievedSpecs: string[];
|
||||
priorityScore: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute variance of an array of numbers.
|
||||
*/
|
||||
export interface SearchResult {
|
||||
topK: PlanOutcome[];
|
||||
setAnalyses: SetAnalysis[];
|
||||
partial: boolean;
|
||||
iterations: number;
|
||||
iterationsTotal: number;
|
||||
}
|
||||
|
||||
export interface SearchCallbacks {
|
||||
onTopKUpdate?: (topK: PlanOutcome[], iterations: number) => void;
|
||||
onChoiceUpdate?: (setId: string, analysis: SetAnalysis) => void;
|
||||
onProgress?: (iterations: number, iterationsTotal: number) => void;
|
||||
onLeafEvaluated?: (leaf: PlanOutcome) => void;
|
||||
}
|
||||
|
||||
const MAX_OPEN_SETS_FOR_ENUMERATION = 9;
|
||||
const CREDIT_THRESHOLD = 9;
|
||||
export const PROGRESS_THROTTLE_MS = 100;
|
||||
|
||||
function variance(values: number[]): number {
|
||||
if (values.length <= 1) return 0;
|
||||
const mean = values.reduce((a, b) => a + b, 0) / values.length;
|
||||
return values.reduce((sum, v) => sum + (v - mean) ** 2, 0) / values.length;
|
||||
}
|
||||
|
||||
export function selectPriorityTarget(
|
||||
ranking: string[],
|
||||
upperBounds: Record<string, number>,
|
||||
): string | null {
|
||||
for (const specId of ranking) {
|
||||
if ((upperBounds[specId] ?? 0) >= CREDIT_THRESHOLD) return specId;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function reorderForTarget(
|
||||
setId: string,
|
||||
target: string | null,
|
||||
excludedCourseIds?: Set<string>,
|
||||
): Course[] {
|
||||
const courses = coursesBySet[setId].filter(
|
||||
(c) => !excludedCourseIds?.has(c.id),
|
||||
);
|
||||
if (!target) return courses;
|
||||
const qualifying: Course[] = [];
|
||||
const others: Course[] = [];
|
||||
for (const c of courses) {
|
||||
if (c.qualifications.some((q) => q.specId === target)) qualifying.push(c);
|
||||
else others.push(c);
|
||||
}
|
||||
return [...qualifying, ...others];
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze all open sets and compute per-choice ceiling outcomes.
|
||||
* Returns sets ordered by decision impact (highest first).
|
||||
*
|
||||
* onSetComplete is called progressively as each set's analysis finishes.
|
||||
* Reorder a set's courses so those qualifying for the most reachable specs
|
||||
* (upperBound >= 9) come first. Stable sort: ties keep declaration order.
|
||||
* Used by maximize-count mode to surface generalist courses early.
|
||||
*/
|
||||
export function reorderByReachableQualCount(
|
||||
setId: string,
|
||||
upperBounds: Record<string, number>,
|
||||
excludedCourseIds?: Set<string>,
|
||||
): Course[] {
|
||||
const courses = coursesBySet[setId].filter(
|
||||
(c) => !excludedCourseIds?.has(c.id),
|
||||
);
|
||||
// Decorate-sort-undecorate for stability
|
||||
return courses
|
||||
.map((course, idx) => ({
|
||||
course,
|
||||
idx,
|
||||
score: course.qualifications.filter(
|
||||
(q) => (upperBounds[q.specId] ?? 0) >= CREDIT_THRESHOLD,
|
||||
).length,
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
if (b.score !== a.score) return b.score - a.score;
|
||||
return a.idx - b.idx;
|
||||
})
|
||||
.map((x) => x.course);
|
||||
}
|
||||
|
||||
export function assignmentKey(assignments: Record<string, string>): string {
|
||||
return Object.keys(assignments)
|
||||
.sort()
|
||||
.map((k) => `${k}:${assignments[k]}`)
|
||||
.join('|');
|
||||
}
|
||||
|
||||
export class BoundedRankedList<T> {
|
||||
private items: T[] = [];
|
||||
constructor(
|
||||
private capacity: number,
|
||||
private compare: (a: T, b: T) => number,
|
||||
) {}
|
||||
|
||||
tryInsert(item: T): boolean {
|
||||
let pos = 0;
|
||||
while (pos < this.items.length && this.compare(item, this.items[pos]) > 0) pos++;
|
||||
if (pos >= this.capacity) return false;
|
||||
this.items.splice(pos, 0, item);
|
||||
if (this.items.length > this.capacity) this.items.pop();
|
||||
return true;
|
||||
}
|
||||
|
||||
toArray(): T[] {
|
||||
return [...this.items];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Comparator for plan outcomes. Mode-dependent ordering uses lexicographic
|
||||
* rank-weight (top-ranked spec dominates any combination of lower-ranked
|
||||
* specs):
|
||||
* - priority-order mode: (rankWeight desc, count desc, key asc)
|
||||
* - maximize-count mode: (count desc, rankWeight desc, key asc)
|
||||
* Returns negative if a is better, positive if b is better.
|
||||
*/
|
||||
export function makeOutcomeComparator(
|
||||
mode: OptimizationMode,
|
||||
ranking: string[],
|
||||
): (a: PlanOutcome, b: PlanOutcome) => number {
|
||||
const rankWeight = makePriorityRankWeight(ranking);
|
||||
return (a, b) => {
|
||||
const aw = rankWeight(a.achievedSpecs);
|
||||
const bw = rankWeight(b.achievedSpecs);
|
||||
if (mode === 'priority-order') {
|
||||
if (aw !== bw) return bw - aw;
|
||||
if (a.achievedSpecs.length !== b.achievedSpecs.length) return b.achievedSpecs.length - a.achievedSpecs.length;
|
||||
} else {
|
||||
if (a.achievedSpecs.length !== b.achievedSpecs.length) return b.achievedSpecs.length - a.achievedSpecs.length;
|
||||
if (aw !== bw) return bw - aw;
|
||||
}
|
||||
return assignmentKey(a.courseAssignments).localeCompare(
|
||||
assignmentKey(b.courseAssignments),
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
/** Default count-first comparator (uses default ranking), retained for backward compatibility with tests. */
|
||||
export function compareOutcomes(a: PlanOutcome, b: PlanOutcome): number {
|
||||
// Default to alphabetical ranking; tests using this directly only exercise
|
||||
// simple count/key cases that are insensitive to ranking.
|
||||
return makeOutcomeComparator('maximize-count', [])(a, b);
|
||||
}
|
||||
|
||||
interface CeilingComparable {
|
||||
count: number;
|
||||
specs: string[];
|
||||
key: string;
|
||||
}
|
||||
|
||||
function makeCeilingComparator(
|
||||
mode: OptimizationMode,
|
||||
ranking: string[],
|
||||
): (a: CeilingComparable, b: CeilingComparable) => number {
|
||||
const rankWeight = makePriorityRankWeight(ranking);
|
||||
return (a, b) => {
|
||||
const aw = rankWeight(a.specs);
|
||||
const bw = rankWeight(b.specs);
|
||||
if (mode === 'priority-order') {
|
||||
if (aw !== bw) return bw - aw;
|
||||
if (a.count !== b.count) return b.count - a.count;
|
||||
} else {
|
||||
if (a.count !== b.count) return b.count - a.count;
|
||||
if (aw !== bw) return bw - aw;
|
||||
}
|
||||
return a.key.localeCompare(b.key);
|
||||
};
|
||||
}
|
||||
|
||||
export function searchDecisionTree(
|
||||
pinnedCourseIds: string[],
|
||||
openSetIds: string[],
|
||||
ranking: string[],
|
||||
mode: OptimizationMode,
|
||||
K: number,
|
||||
callbacks?: SearchCallbacks,
|
||||
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);
|
||||
const upperBounds = computeUpperBounds(
|
||||
pinnedCourseIds,
|
||||
openSetIds,
|
||||
excludedCourseIds,
|
||||
externalCredits,
|
||||
);
|
||||
const priorityTarget = selectPriorityTarget(ranking, upperBounds);
|
||||
// Pinned assignments (setId -> courseId) for any pinned sets — included in
|
||||
// the leaf's full courseAssignments so cache keys are stable across pin/unpin.
|
||||
const pinnedMap = pinnedAssignments ?? {};
|
||||
|
||||
// Initialize per-set analyses with unevaluated cells, ordered by mode
|
||||
const setAnalyses: Record<string, SetAnalysis> = {};
|
||||
const orderedCoursesPerSet: Record<string, Course[]> = {};
|
||||
let iterationsTotal = 1;
|
||||
for (const setId of openSetIds) {
|
||||
const set = ELECTIVE_SETS.find((s) => s.id === setId)!;
|
||||
const ordered =
|
||||
mode === 'maximize-count'
|
||||
? reorderByReachableQualCount(setId, upperBounds, excludedCourseIds)
|
||||
: reorderForTarget(setId, priorityTarget, excludedCourseIds);
|
||||
orderedCoursesPerSet[setId] = ordered;
|
||||
iterationsTotal *= ordered.length || 1;
|
||||
setAnalyses[setId] = {
|
||||
setId,
|
||||
setName: set.name,
|
||||
impact: 0,
|
||||
choices: ordered.map((c) => ({
|
||||
courseId: c.id,
|
||||
courseName: c.name,
|
||||
ceilingCount: 0,
|
||||
ceilingSpecs: [],
|
||||
evaluated: false,
|
||||
})),
|
||||
};
|
||||
}
|
||||
const choiceKey: Record<string, string> = {};
|
||||
|
||||
const outcomeComparator = makeOutcomeComparator(mode, ranking);
|
||||
const ceilingComparator = makeCeilingComparator(mode, ranking);
|
||||
const topK = new BoundedRankedList<PlanOutcome>(K, outcomeComparator);
|
||||
let iterations = 0;
|
||||
const partial = false;
|
||||
let lastProgressEmit = 0;
|
||||
|
||||
function emitProgress() {
|
||||
if (!callbacks?.onProgress) return;
|
||||
const now = Date.now();
|
||||
if (now - lastProgressEmit >= PROGRESS_THROTTLE_MS) {
|
||||
lastProgressEmit = now;
|
||||
callbacks.onProgress(iterations, iterationsTotal);
|
||||
}
|
||||
}
|
||||
|
||||
function evaluateLeaf(accumulated: Record<string, string>): void {
|
||||
iterations++;
|
||||
|
||||
// Build the full 12-set assignment so cache keys remain stable across
|
||||
// pin/unpin operations.
|
||||
const fullAssignment: Record<string, string> = { ...pinnedMap, ...accumulated };
|
||||
const aKey = assignmentKey(fullAssignment);
|
||||
if (skipKeys?.has(aKey)) {
|
||||
emitProgress();
|
||||
return;
|
||||
}
|
||||
|
||||
const courses: string[] = [];
|
||||
for (const setId of openSetIds) courses.push(accumulated[setId]);
|
||||
const selected = [...pinnedCourseIds, ...courses];
|
||||
const result = fn(selected, ranking, [], excludedCourseIds, externalCredits);
|
||||
const score = scorer(result.achieved);
|
||||
|
||||
const outcome: PlanOutcome = {
|
||||
courseAssignments: fullAssignment,
|
||||
achievedSpecs: result.achieved,
|
||||
priorityScore: score,
|
||||
};
|
||||
|
||||
callbacks?.onLeafEvaluated?.(outcome);
|
||||
|
||||
if (topK.tryInsert(outcome)) {
|
||||
callbacks?.onTopKUpdate?.(topK.toArray(), iterations);
|
||||
}
|
||||
|
||||
// Per-set ceiling + evaluated-flag updates
|
||||
for (const setId of openSetIds) {
|
||||
const courseId = accumulated[setId];
|
||||
const analysis = setAnalyses[setId];
|
||||
const choice = analysis.choices.find((c) => c.courseId === courseId)!;
|
||||
const wasEvaluated = choice.evaluated;
|
||||
const currentKey = `${setId}:${courseId}`;
|
||||
const existing: CeilingComparable = {
|
||||
count: choice.ceilingCount,
|
||||
specs: choice.ceilingSpecs,
|
||||
key: choiceKey[currentKey] ?? '',
|
||||
};
|
||||
const candidate: CeilingComparable = {
|
||||
count: result.achieved.length,
|
||||
specs: result.achieved,
|
||||
key: aKey,
|
||||
};
|
||||
const ceilingImproved = ceilingComparator(candidate, existing) < 0;
|
||||
if (ceilingImproved) {
|
||||
choice.ceilingCount = candidate.count;
|
||||
choice.ceilingSpecs = result.achieved;
|
||||
choiceKey[currentKey] = aKey;
|
||||
}
|
||||
// Mark evaluated regardless of improvement
|
||||
choice.evaluated = true;
|
||||
|
||||
if (!wasEvaluated || ceilingImproved) {
|
||||
const impact = variance(analysis.choices.map((c) => c.ceilingCount));
|
||||
analysis.impact = impact;
|
||||
const updated: SetAnalysis = {
|
||||
...analysis,
|
||||
impact,
|
||||
choices: analysis.choices.map((c) => ({ ...c })),
|
||||
};
|
||||
callbacks?.onChoiceUpdate?.(setId, updated);
|
||||
}
|
||||
}
|
||||
|
||||
emitProgress();
|
||||
}
|
||||
|
||||
function dfs(setIdx: number, accumulated: Record<string, string>) {
|
||||
if (setIdx >= openSetIds.length) {
|
||||
evaluateLeaf(accumulated);
|
||||
return;
|
||||
}
|
||||
const setId = openSetIds[setIdx];
|
||||
const courses = orderedCoursesPerSet[setId];
|
||||
for (const course of courses) {
|
||||
accumulated[setId] = course.id;
|
||||
dfs(setIdx + 1, accumulated);
|
||||
}
|
||||
delete accumulated[setId];
|
||||
}
|
||||
|
||||
if (openSetIds.length > 0 && openSetIds.every((s) => orderedCoursesPerSet[s].length > 0)) {
|
||||
dfs(0, {});
|
||||
}
|
||||
|
||||
// Final progress emit so consumers see the completion count
|
||||
if (callbacks?.onProgress) callbacks.onProgress(iterations, iterationsTotal);
|
||||
|
||||
// Final impact recomputation + sort
|
||||
for (const a of Object.values(setAnalyses)) {
|
||||
a.impact = variance(a.choices.map((c) => c.ceilingCount));
|
||||
}
|
||||
const setOrder = new Map(ELECTIVE_SETS.map((s, i) => [s.id, i]));
|
||||
const sortedAnalyses = Object.values(setAnalyses).sort((a, b) => {
|
||||
if (b.impact !== a.impact) return b.impact - a.impact;
|
||||
return (setOrder.get(a.setId) ?? 0) - (setOrder.get(b.setId) ?? 0);
|
||||
});
|
||||
|
||||
return {
|
||||
topK: topK.toArray(),
|
||||
setAnalyses: sortedAnalyses,
|
||||
partial,
|
||||
iterations,
|
||||
iterationsTotal,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure derivation of {topK, setAnalyses} from a collection of leaf outcomes.
|
||||
* Used by the main thread when filtering the leaf cache, and reusable
|
||||
* elsewhere as needed. Does NOT run any optimizer calls — leaves carry
|
||||
* their own pre-computed achievedSpecs/priorityScore.
|
||||
*/
|
||||
export function deriveFromLeaves(
|
||||
leaves: Iterable<PlanOutcome>,
|
||||
K: number,
|
||||
mode: OptimizationMode,
|
||||
ranking: string[],
|
||||
openSetIds: string[],
|
||||
excludedCourseIds?: Set<string>,
|
||||
externalCredits?: Record<string, number>,
|
||||
): { topK: PlanOutcome[]; setAnalyses: SetAnalysis[] } {
|
||||
const upperBounds = computeUpperBounds([], openSetIds, excludedCourseIds, externalCredits);
|
||||
const priorityTarget = selectPriorityTarget(ranking, upperBounds);
|
||||
|
||||
const setAnalyses: Record<string, SetAnalysis> = {};
|
||||
for (const setId of openSetIds) {
|
||||
const set = ELECTIVE_SETS.find((s) => s.id === setId)!;
|
||||
const ordered =
|
||||
mode === 'maximize-count'
|
||||
? reorderByReachableQualCount(setId, upperBounds, excludedCourseIds)
|
||||
: reorderForTarget(setId, priorityTarget, excludedCourseIds);
|
||||
setAnalyses[setId] = {
|
||||
setId,
|
||||
setName: set.name,
|
||||
impact: 0,
|
||||
choices: ordered.map((c) => ({
|
||||
courseId: c.id,
|
||||
courseName: c.name,
|
||||
ceilingCount: 0,
|
||||
ceilingSpecs: [],
|
||||
evaluated: false,
|
||||
})),
|
||||
};
|
||||
}
|
||||
const choiceKey: Record<string, string> = {};
|
||||
const ceilingComparator = makeCeilingComparator(mode, ranking);
|
||||
const outcomeComparator = makeOutcomeComparator(mode, ranking);
|
||||
const topK = new BoundedRankedList<PlanOutcome>(K, outcomeComparator);
|
||||
|
||||
for (const leaf of leaves) {
|
||||
topK.tryInsert(leaf);
|
||||
const aKey = assignmentKey(leaf.courseAssignments);
|
||||
for (const setId of openSetIds) {
|
||||
const courseId = leaf.courseAssignments[setId];
|
||||
if (!courseId) continue;
|
||||
const analysis = setAnalyses[setId];
|
||||
const choice = analysis.choices.find((c) => c.courseId === courseId);
|
||||
if (!choice) continue;
|
||||
const currentKey = `${setId}:${courseId}`;
|
||||
const existing: CeilingComparable = {
|
||||
count: choice.ceilingCount,
|
||||
specs: choice.ceilingSpecs,
|
||||
key: choiceKey[currentKey] ?? '',
|
||||
};
|
||||
const candidate: CeilingComparable = {
|
||||
count: leaf.achievedSpecs.length,
|
||||
specs: leaf.achievedSpecs,
|
||||
key: aKey,
|
||||
};
|
||||
if (ceilingComparator(candidate, existing) < 0) {
|
||||
choice.ceilingCount = candidate.count;
|
||||
choice.ceilingSpecs = leaf.achievedSpecs;
|
||||
choiceKey[currentKey] = aKey;
|
||||
}
|
||||
choice.evaluated = true;
|
||||
}
|
||||
}
|
||||
|
||||
for (const a of Object.values(setAnalyses)) {
|
||||
a.impact = variance(a.choices.map((c) => c.ceilingCount));
|
||||
}
|
||||
const setOrder = new Map(ELECTIVE_SETS.map((s, i) => [s.id, i]));
|
||||
const sortedAnalyses = Object.values(setAnalyses).sort((a, b) => {
|
||||
if (b.impact !== a.impact) return b.impact - a.impact;
|
||||
return (setOrder.get(a.setId) ?? 0) - (setOrder.get(b.setId) ?? 0);
|
||||
});
|
||||
|
||||
return { topK: topK.toArray(), setAnalyses: sortedAnalyses };
|
||||
}
|
||||
|
||||
/**
|
||||
* Backward-compatible wrapper: produces only the per-set ceiling table.
|
||||
* Internally runs searchDecisionTree with K=10 and emits each set's analysis
|
||||
* once per choice update via the legacy onSetComplete callback.
|
||||
*/
|
||||
export function analyzeDecisionTree(
|
||||
pinnedCourseIds: string[],
|
||||
@@ -96,52 +474,21 @@ export function analyzeDecisionTree(
|
||||
excludedCourseIds?: Set<string>,
|
||||
): SetAnalysis[] {
|
||||
if (openSetIds.length > MAX_OPEN_SETS_FOR_ENUMERATION) {
|
||||
// Fallback: return empty analyses (caller uses upper bounds instead)
|
||||
return openSetIds.map((setId) => {
|
||||
const set = ELECTIVE_SETS.find((s) => s.id === setId)!;
|
||||
return { setId, setName: set.name, impact: 0, choices: [] };
|
||||
});
|
||||
}
|
||||
|
||||
const analyses: SetAnalysis[] = [];
|
||||
|
||||
for (const setId of openSetIds) {
|
||||
const set = ELECTIVE_SETS.find((s) => s.id === setId)!;
|
||||
const otherOpenSets = openSetIds.filter((id) => id !== setId);
|
||||
const courses = coursesBySet[setId];
|
||||
|
||||
const choices: ChoiceOutcome[] = courses
|
||||
.filter((course) => !excludedCourseIds?.has(course.id))
|
||||
.map((course) => {
|
||||
const ceiling = computeCeiling(
|
||||
pinnedCourseIds,
|
||||
course.id,
|
||||
otherOpenSets,
|
||||
ranking,
|
||||
mode,
|
||||
excludedCourseIds,
|
||||
);
|
||||
return {
|
||||
courseId: course.id,
|
||||
courseName: course.name,
|
||||
ceilingCount: ceiling.count,
|
||||
ceilingSpecs: ceiling.specs,
|
||||
};
|
||||
});
|
||||
|
||||
const impact = variance(choices.map((c) => c.ceilingCount));
|
||||
const analysis: SetAnalysis = { setId, setName: set.name, impact, choices };
|
||||
analyses.push(analysis);
|
||||
|
||||
onSetComplete?.(analysis);
|
||||
}
|
||||
|
||||
// Sort by impact descending, then by set order (chronological) for ties
|
||||
const setOrder = new Map(ELECTIVE_SETS.map((s, i) => [s.id, i]));
|
||||
analyses.sort((a, b) => {
|
||||
if (b.impact !== a.impact) return b.impact - a.impact;
|
||||
return (setOrder.get(a.setId) ?? 0) - (setOrder.get(b.setId) ?? 0);
|
||||
});
|
||||
|
||||
return analyses;
|
||||
const result = searchDecisionTree(
|
||||
pinnedCourseIds,
|
||||
openSetIds,
|
||||
ranking,
|
||||
mode,
|
||||
10,
|
||||
onSetComplete
|
||||
? { onChoiceUpdate: (_setId, analysis) => onSetComplete(analysis) }
|
||||
: undefined,
|
||||
excludedCourseIds,
|
||||
);
|
||||
return result.setAnalyses;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
+34
-24
@@ -8,6 +8,7 @@ import {
|
||||
preFilterCandidates,
|
||||
computeUpperBounds,
|
||||
} from './feasibility';
|
||||
import { makePriorityScorer } from './priority';
|
||||
|
||||
const CREDIT_THRESHOLD = 9;
|
||||
const CREDIT_PER_COURSE = 2.5;
|
||||
@@ -33,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: {} };
|
||||
@@ -57,23 +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));
|
||||
});
|
||||
|
||||
// Priority score: sum of (15 - rank position) for each spec in subset
|
||||
const rankIndex = new Map(ranking.map((id, i) => [id, i]));
|
||||
function priorityScore(specs: string[]): number {
|
||||
return specs.reduce((sum, id) => sum + (15 - (rankIndex.get(id) ?? 14)), 0);
|
||||
}
|
||||
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);
|
||||
@@ -85,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) {
|
||||
@@ -110,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),
|
||||
@@ -126,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;
|
||||
@@ -142,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) {
|
||||
@@ -161,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)!;
|
||||
@@ -172,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;
|
||||
@@ -187,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';
|
||||
@@ -206,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 };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { SPECIALIZATIONS } from '../data/specializations';
|
||||
|
||||
const FALLBACK_RANK = SPECIALIZATIONS.length - 1;
|
||||
const MAX_RANK_WEIGHT = SPECIALIZATIONS.length;
|
||||
|
||||
export function priorityScore(specs: string[], ranking: string[]): number {
|
||||
const rankIndex = new Map(ranking.map((id, i) => [id, i]));
|
||||
return specs.reduce(
|
||||
(sum, id) => sum + (MAX_RANK_WEIGHT - (rankIndex.get(id) ?? FALLBACK_RANK)),
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
export function makePriorityScorer(ranking: string[]): (specs: string[]) => number {
|
||||
const rankIndex = new Map(ranking.map((id, i) => [id, i]));
|
||||
return (specs) =>
|
||||
specs.reduce(
|
||||
(sum, id) => sum + (MAX_RANK_WEIGHT - (rankIndex.get(id) ?? FALLBACK_RANK)),
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lexicographic comparison weight: each spec encoded as a bit, top-ranked
|
||||
* spec = highest bit. Higher rankWeight means a strictly preferred plan
|
||||
* under priority order (a single top-ranked spec outweighs any combination
|
||||
* of lower-ranked specs). Used by comparators; not for display.
|
||||
*/
|
||||
export function priorityRankWeight(specs: string[], ranking: string[]): number {
|
||||
const rankIndex = new Map(ranking.map((id, i) => [id, i]));
|
||||
const N = ranking.length;
|
||||
let w = 0;
|
||||
for (const id of specs) {
|
||||
const r = rankIndex.get(id);
|
||||
if (r === undefined) continue;
|
||||
w += 1 << (N - 1 - r);
|
||||
}
|
||||
return w;
|
||||
}
|
||||
|
||||
export function makePriorityRankWeight(ranking: string[]): (specs: string[]) => number {
|
||||
const rankIndex = new Map(ranking.map((id, i) => [id, i]));
|
||||
const N = ranking.length;
|
||||
return (specs) => {
|
||||
let w = 0;
|
||||
for (const id of specs) {
|
||||
const r = rankIndex.get(id);
|
||||
if (r === undefined) continue;
|
||||
w += 1 << (N - 1 - r);
|
||||
}
|
||||
return w;
|
||||
};
|
||||
}
|
||||
+195
-14
@@ -3,17 +3,36 @@ import { SPECIALIZATIONS } from '../data/specializations';
|
||||
import { ELECTIVE_SETS } from '../data/electiveSets';
|
||||
import type { OptimizationMode, AllocationResult } from '../data/types';
|
||||
import { optimize } from '../solver/optimizer';
|
||||
import type { SetAnalysis } from '../solver/decisionTree';
|
||||
import {
|
||||
assignmentKey,
|
||||
deriveFromLeaves,
|
||||
reorderByReachableQualCount,
|
||||
reorderForTarget,
|
||||
selectPriorityTarget,
|
||||
} from '../solver/decisionTree';
|
||||
import { computeUpperBounds } from '../solver/feasibility';
|
||||
import { makePriorityScorer } from '../solver/priority';
|
||||
import type { SetAnalysis, PlanOutcome } from '../solver/decisionTree';
|
||||
import type { WorkerRequest, WorkerResponse } from '../workers/decisionTree.worker';
|
||||
import { cancelledCourseIds, courseIdsByName, courseById } from '../data/lookups';
|
||||
import DecisionTreeWorker from '../workers/decisionTree.worker?worker';
|
||||
|
||||
const LEAF_CACHE_CAP = 500_000;
|
||||
|
||||
interface LeafCache {
|
||||
ranking: string[];
|
||||
mode: OptimizationMode;
|
||||
externalCreditsKey: string;
|
||||
leaves: Map<string, PlanOutcome>;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'emba-solver-state';
|
||||
|
||||
export interface AppState {
|
||||
ranking: string[];
|
||||
mode: OptimizationMode;
|
||||
pinnedCourses: Record<string, string | null>; // setId -> courseId | null
|
||||
externalCredits: Record<string, number>; // specId -> credits
|
||||
}
|
||||
|
||||
type AppAction =
|
||||
@@ -21,6 +40,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 {
|
||||
@@ -36,6 +56,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: {} };
|
||||
}
|
||||
@@ -46,32 +73,58 @@ 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);
|
||||
if (!raw) return defaultState();
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed.ranking) || parsed.ranking.length !== 14) return defaultState();
|
||||
if (!Array.isArray(parsed.ranking) || parsed.ranking.length !== SPECIALIZATIONS.length) return defaultState();
|
||||
if (!['maximize-count', 'priority-order'].includes(parsed.mode)) return defaultState();
|
||||
return {
|
||||
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[]>([]);
|
||||
const [treeLoading, setTreeLoading] = useState(false);
|
||||
const [topPlans, setTopPlans] = useState<PlanOutcome[]>([]);
|
||||
const [topPlansPartial, setTopPlansPartial] = useState(false);
|
||||
const [searchProgress, setSearchProgress] = useState<{ iterations: number; iterationsTotal: number } | null>(null);
|
||||
const workerRef = useRef<Worker | null>(null);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
const leafCacheRef = useRef<LeafCache>({
|
||||
ranking: state.ranking,
|
||||
mode: state.mode,
|
||||
externalCreditsKey: externalCreditsKey(state.externalCredits),
|
||||
leaves: new Map(),
|
||||
});
|
||||
|
||||
// Persist to localStorage
|
||||
useEffect(() => {
|
||||
@@ -113,37 +166,150 @@ 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],
|
||||
);
|
||||
|
||||
// Web Worker decision tree (debounced)
|
||||
// Pinned assignments map (setId -> courseId) for the cache + worker
|
||||
const pinnedAssignments = useMemo(() => {
|
||||
const out: Record<string, string> = {};
|
||||
for (const [setId, courseId] of Object.entries(state.pinnedCourses)) {
|
||||
if (courseId) out[setId] = courseId;
|
||||
}
|
||||
return out;
|
||||
}, [state.pinnedCourses]);
|
||||
|
||||
// Web Worker decision tree (debounced) — with leaf cache short-circuit
|
||||
useEffect(() => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
|
||||
if (openSetIds.length === 0) {
|
||||
// Selection is complete — synthesize a single PlanOutcome from the
|
||||
// pinned assignments so Top Plans can render the user's completed plan.
|
||||
const scorer = makePriorityScorer(state.ranking);
|
||||
const completed: PlanOutcome = {
|
||||
courseAssignments: pinnedAssignments,
|
||||
achievedSpecs: optimizationResult.achieved,
|
||||
priorityScore: scorer(optimizationResult.achieved),
|
||||
};
|
||||
setTreeResults([]);
|
||||
setTopPlans([completed]);
|
||||
setTopPlansPartial(false);
|
||||
setSearchProgress(null);
|
||||
setTreeLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 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]);
|
||||
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, state.externalCredits);
|
||||
const priorityTarget = selectPriorityTarget(state.ranking, upperBounds);
|
||||
const orderedCoursesPerSet: Record<string, ReturnType<typeof reorderForTarget>> = {};
|
||||
let expectedTotal = 1;
|
||||
for (const setId of openSetIds) {
|
||||
const ordered =
|
||||
state.mode === 'maximize-count'
|
||||
? reorderByReachableQualCount(setId, upperBounds, excludedCourseIds)
|
||||
: reorderForTarget(setId, priorityTarget, excludedCourseIds);
|
||||
orderedCoursesPerSet[setId] = ordered;
|
||||
expectedTotal *= ordered.length || 1;
|
||||
}
|
||||
|
||||
// Filter cache to leaves matching the current pinned + excluded state
|
||||
const filtered: PlanOutcome[] = [];
|
||||
for (const leaf of cache.leaves.values()) {
|
||||
// Every pinned set's assignment must match
|
||||
let ok = true;
|
||||
for (const [pinSet, pinCourse] of Object.entries(pinnedAssignments)) {
|
||||
if (leaf.courseAssignments[pinSet] !== pinCourse) { ok = false; break; }
|
||||
}
|
||||
if (!ok) continue;
|
||||
// No excluded courses may appear in the leaf's assignments
|
||||
for (const courseId of Object.values(leaf.courseAssignments)) {
|
||||
if (excludedCourseIds.has(courseId)) { ok = false; break; }
|
||||
}
|
||||
if (!ok) continue;
|
||||
// Each open-set assignment in the leaf must be one of the currently-orderedCoursesPerSet entries
|
||||
for (const setId of openSetIds) {
|
||||
const v = leaf.courseAssignments[setId];
|
||||
if (!v || !orderedCoursesPerSet[setId].some((c) => c.id === v)) { ok = false; break; }
|
||||
}
|
||||
if (ok) filtered.push(leaf);
|
||||
}
|
||||
|
||||
// Derive UI state from filtered cache and render immediately
|
||||
const { topK: cachedTopK, setAnalyses: cachedAnalyses } = deriveFromLeaves(
|
||||
filtered,
|
||||
10,
|
||||
state.mode,
|
||||
state.ranking,
|
||||
openSetIds,
|
||||
excludedCourseIds,
|
||||
state.externalCredits,
|
||||
);
|
||||
setTreeResults(cachedAnalyses);
|
||||
setTopPlans(cachedTopK);
|
||||
setTopPlansPartial(false);
|
||||
setSearchProgress({ iterations: filtered.length, iterationsTotal: expectedTotal });
|
||||
|
||||
// Full cache hit — no worker needed
|
||||
if (filtered.length >= expectedTotal) {
|
||||
setTreeLoading(false);
|
||||
// Make sure any in-flight worker is shut down
|
||||
if (workerRef.current) {
|
||||
workerRef.current.terminate();
|
||||
workerRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Partial hit — spawn worker to fill in the missing leaves
|
||||
setTreeLoading(true);
|
||||
|
||||
debounceRef.current = setTimeout(() => {
|
||||
// Terminate previous worker if still running
|
||||
if (workerRef.current) workerRef.current.terminate();
|
||||
|
||||
try {
|
||||
const worker = new DecisionTreeWorker();
|
||||
workerRef.current = worker;
|
||||
|
||||
const progressResults: SetAnalysis[] = [];
|
||||
const setMap = new Map<string, SetAnalysis>();
|
||||
for (const a of cachedAnalyses) setMap.set(a.setId, a);
|
||||
|
||||
worker.onmessage = (e: MessageEvent<WorkerResponse>) => {
|
||||
if (e.data.type === 'setComplete' && e.data.analysis) {
|
||||
progressResults.push(e.data.analysis);
|
||||
setTreeResults([...progressResults]);
|
||||
} else if (e.data.type === 'allComplete' && e.data.analyses) {
|
||||
setTreeResults(e.data.analyses);
|
||||
if (e.data.type === 'choiceUpdate') {
|
||||
setMap.set(e.data.setId, e.data.analysis);
|
||||
setTreeResults(Array.from(setMap.values()));
|
||||
} else if (e.data.type === 'topKUpdate') {
|
||||
setTopPlans(e.data.topK);
|
||||
} else if (e.data.type === 'progress') {
|
||||
setSearchProgress({ iterations: e.data.iterations, iterationsTotal: e.data.iterationsTotal });
|
||||
} else if (e.data.type === 'leafEvaluated') {
|
||||
const cache = leafCacheRef.current;
|
||||
// Stop accepting new entries once the cap is reached; retain
|
||||
// existing entries as a warm starting point for subsequent
|
||||
// pin/unpin operations.
|
||||
if (cache.leaves.size < LEAF_CACHE_CAP) {
|
||||
const key = assignmentKey(e.data.leaf.courseAssignments);
|
||||
cache.leaves.set(key, e.data.leaf);
|
||||
}
|
||||
} else if (e.data.type === 'allComplete') {
|
||||
setTreeResults(e.data.setAnalyses);
|
||||
setTopPlans(e.data.topK);
|
||||
setTopPlansPartial(e.data.partial);
|
||||
setSearchProgress({ iterations: e.data.iterations, iterationsTotal: e.data.iterationsTotal });
|
||||
setTreeLoading(false);
|
||||
worker.terminate();
|
||||
workerRef.current = null;
|
||||
@@ -152,14 +318,17 @@ export function useAppState() {
|
||||
|
||||
const request: WorkerRequest = {
|
||||
pinnedCourseIds: selectedCourseIds,
|
||||
pinnedAssignments,
|
||||
openSetIds,
|
||||
ranking: state.ranking,
|
||||
mode: state.mode,
|
||||
excludedCourseIds: [...excludedCourseIds],
|
||||
topK: 10,
|
||||
skipKeys: filtered.map((l) => assignmentKey(l.courseAssignments)),
|
||||
externalCredits: state.externalCredits,
|
||||
};
|
||||
worker.postMessage(request);
|
||||
} catch {
|
||||
// Web Worker not available (e.g., test env) — skip
|
||||
setTreeLoading(false);
|
||||
}
|
||||
}, 300);
|
||||
@@ -171,19 +340,29 @@ export function useAppState() {
|
||||
workerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [selectedCourseIds, openSetIds, state.ranking, state.mode, excludedCourseIds]);
|
||||
}, [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>) => {
|
||||
for (const [setId, courseId] of Object.entries(assignments)) {
|
||||
dispatch({ type: 'pinCourse', setId, courseId });
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
state,
|
||||
optimizationResult,
|
||||
treeResults,
|
||||
treeLoading,
|
||||
topPlans,
|
||||
topPlansPartial,
|
||||
searchProgress,
|
||||
openSetIds,
|
||||
selectedCourseIds,
|
||||
disabledCourseIds,
|
||||
@@ -192,6 +371,8 @@ export function useAppState() {
|
||||
setMode,
|
||||
pinCourse,
|
||||
unpinCourse,
|
||||
setExternalCredit,
|
||||
clearAll,
|
||||
adoptPlan,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,41 +1,91 @@
|
||||
import { analyzeDecisionTree } from '../solver/decisionTree';
|
||||
import { searchDecisionTree } from '../solver/decisionTree';
|
||||
import type { OptimizationMode } from '../data/types';
|
||||
import type { SetAnalysis } from '../solver/decisionTree';
|
||||
import type { SetAnalysis, PlanOutcome } from '../solver/decisionTree';
|
||||
|
||||
export interface WorkerRequest {
|
||||
pinnedCourseIds: string[];
|
||||
pinnedAssignments?: Record<string, string>;
|
||||
openSetIds: string[];
|
||||
ranking: string[];
|
||||
mode: OptimizationMode;
|
||||
excludedCourseIds?: string[];
|
||||
topK?: number;
|
||||
saturationLimit?: number;
|
||||
skipKeys?: string[];
|
||||
externalCredits?: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface WorkerResponse {
|
||||
type: 'setComplete' | 'allComplete';
|
||||
analysis?: SetAnalysis;
|
||||
analyses?: SetAnalysis[];
|
||||
}
|
||||
export type WorkerResponse =
|
||||
| { type: 'topKUpdate'; topK: PlanOutcome[]; iterations: number }
|
||||
| { type: 'choiceUpdate'; setId: string; analysis: SetAnalysis }
|
||||
| { type: 'progress'; iterations: number; iterationsTotal: number }
|
||||
| { type: 'leafEvaluated'; leaf: PlanOutcome }
|
||||
| {
|
||||
type: 'allComplete';
|
||||
topK: PlanOutcome[];
|
||||
setAnalyses: SetAnalysis[];
|
||||
partial: boolean;
|
||||
iterations: number;
|
||||
iterationsTotal: number;
|
||||
};
|
||||
|
||||
self.onmessage = (e: MessageEvent<WorkerRequest>) => {
|
||||
const { pinnedCourseIds, openSetIds, ranking, mode, excludedCourseIds } = e.data;
|
||||
const excludedSet = excludedCourseIds && excludedCourseIds.length > 0
|
||||
? new Set(excludedCourseIds)
|
||||
: undefined;
|
||||
|
||||
const analyses = analyzeDecisionTree(
|
||||
const {
|
||||
pinnedCourseIds,
|
||||
openSetIds,
|
||||
ranking,
|
||||
mode,
|
||||
(analysis) => {
|
||||
// Progressive update: send each set's results as they complete
|
||||
const response: WorkerResponse = { type: 'setComplete', analysis };
|
||||
self.postMessage(response);
|
||||
excludedCourseIds,
|
||||
topK = 10,
|
||||
skipKeys,
|
||||
pinnedAssignments,
|
||||
externalCredits,
|
||||
} = e.data;
|
||||
|
||||
const excludedSet =
|
||||
excludedCourseIds && excludedCourseIds.length > 0
|
||||
? new Set(excludedCourseIds)
|
||||
: undefined;
|
||||
const skipSet =
|
||||
skipKeys && skipKeys.length > 0 ? new Set(skipKeys) : undefined;
|
||||
|
||||
const result = searchDecisionTree(
|
||||
pinnedCourseIds,
|
||||
openSetIds,
|
||||
ranking,
|
||||
mode,
|
||||
topK,
|
||||
{
|
||||
onTopKUpdate: (topK, iterations) => {
|
||||
const msg: WorkerResponse = { type: 'topKUpdate', topK, iterations };
|
||||
self.postMessage(msg);
|
||||
},
|
||||
onChoiceUpdate: (setId, analysis) => {
|
||||
const msg: WorkerResponse = { type: 'choiceUpdate', setId, analysis };
|
||||
self.postMessage(msg);
|
||||
},
|
||||
onProgress: (iterations, iterationsTotal) => {
|
||||
const msg: WorkerResponse = { type: 'progress', iterations, iterationsTotal };
|
||||
self.postMessage(msg);
|
||||
},
|
||||
onLeafEvaluated: (leaf) => {
|
||||
const msg: WorkerResponse = { type: 'leafEvaluated', leaf };
|
||||
self.postMessage(msg);
|
||||
},
|
||||
},
|
||||
excludedSet,
|
||||
skipSet,
|
||||
pinnedAssignments,
|
||||
externalCredits,
|
||||
);
|
||||
|
||||
// Final result with sorted analyses
|
||||
const response: WorkerResponse = { type: 'allComplete', analyses };
|
||||
self.postMessage(response);
|
||||
const final: WorkerResponse = {
|
||||
type: 'allComplete',
|
||||
topK: result.topK,
|
||||
setAnalyses: result.setAnalyses,
|
||||
partial: result.partial,
|
||||
iterations: result.iterations,
|
||||
iterationsTotal: result.iterationsTotal,
|
||||
};
|
||||
self.postMessage(final);
|
||||
};
|
||||
|
||||
+2
-2
@@ -6,8 +6,8 @@ import react from '@vitejs/plugin-react'
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
define: {
|
||||
__APP_VERSION__: JSON.stringify('1.2.1'),
|
||||
__APP_VERSION_DATE__: JSON.stringify('2026-03-27'),
|
||||
__APP_VERSION__: JSON.stringify('1.5.1'),
|
||||
__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
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-05-09
|
||||
@@ -0,0 +1,3 @@
|
||||
# decision-tree-exhaustive-search
|
||||
|
||||
Drop saturation termination; run exhaustive DFS over the open-set cartesian product. Add mode-dependent child ordering (qualifies-for-most-reachable-specs in maximize-count mode, target-first in priority-order mode). Distinguish unevaluated cells from evaluated-zero in the per-set table. Add per-set + global progress indication and a 'Recommended' marker per set.
|
||||
@@ -0,0 +1,99 @@
|
||||
## Context
|
||||
|
||||
v1.3.0 introduced a streaming decision-tree search (`searchDecisionTree`) with two early-termination criteria: a hard iteration cap (`MAX_TREE_ITERATIONS = 10000`) and a saturation criterion (`SATURATION_LIMIT = 500` iterations of no top-K change). The saturation criterion fires too eagerly because the top-K updates only when a *strictly better* outcome is inserted; many leaves produce duplicate outcome classes that don't update top-K but still increment the saturation counter.
|
||||
|
||||
Two visible consequences in the user's testing:
|
||||
|
||||
1. Per-set ceiling table shows "0 specs" for many courses. Root cause: cells are initialized with `{count: 0, specs: []}` and only updated when a leaf containing them is evaluated. The DFS, ordered by the priority-target heuristic, exhausts target-favoring branches first. Saturation fires before the DFS backtracks to non-target choices in early sets.
|
||||
2. Top Plans in maximize-count mode shows only 2-spec outcomes when 3-spec combinations exist. Root cause: the same saturation fires before the search reaches the part of the tree where 3-spec-feasible combinations live (typically generalist-course-heavy combinations).
|
||||
|
||||
Investigation in `app/src/solver/decisionTree.ts:204-225` confirmed both behaviors. A diagnostic showed the user's reproduction case (8 open sets, 49,152 leaves) saturates at ~500 iterations, leaving most cells unevaluated.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Per-set ceiling cells reflect the true best outcome for every (set, course) pair after search completes
|
||||
- Top-K reflects the genuinely-best plans achievable for the given pin/ranking, in either mode
|
||||
- UI distinguishes "still searching" from "search complete, this course achieves nothing"
|
||||
- Search remains responsive: high-quality results appear in the stream within the first ~hundred iterations even though full search takes seconds
|
||||
|
||||
**Non-Goals:**
|
||||
- Sub-second exhaustive search for the 8-open-set worst case (50K leaves × ~1ms LP = ~50s is acceptable in a worker)
|
||||
- Replacing the LP solver or re-architecting the optimizer
|
||||
- Caching across runs
|
||||
- Configurable iteration cap from the UI
|
||||
|
||||
## Decisions
|
||||
|
||||
### Drop saturation termination entirely (not "make it smarter")
|
||||
|
||||
The user explicitly chose Approach A: exhaustive. Smarter saturation criteria (e.g., "stable for N iters AND every cell visited at least once") add complexity without reaching demonstrated correctness — there's always a pathological combination that defeats the heuristic. Exhaustive is simpler, demonstrably correct, and feasible in a worker.
|
||||
|
||||
**Alternative considered:** Two-phase search (fast heuristic + background exhaustive sweep). Rejected — added complexity (phase transition events, partial state), and the user's preference for "exhaustive" was explicit. The progress UI absorbs the same UX pain (user sees the search running) without the implementation cost.
|
||||
|
||||
### Mode-dependent enumeration ordering
|
||||
|
||||
The current target-first heuristic biases toward priority-order mode's expected outcome (high-priority spec achieved early). For maximize-count mode, ordering by qualification breadth is a better fit because generalist courses lead to higher-count plans. Both heuristics are cheap to compute (one upper-bounds map lookup, then a per-course count) and run once per analysis.
|
||||
|
||||
For maximize-count, the score per course is `count of (specId in course.qualifications) where upperBounds[specId] >= 9`. Sorting children by this score descending puts the generalist courses first. Stable sort keeps declaration order on ties.
|
||||
|
||||
**Alternative considered:** A single unified ordering (e.g., always order by qualification breadth). Rejected — for priority-order mode, the user's priority is the meaningful signal; using breadth would suppress the priority spec early in the stream.
|
||||
|
||||
### `evaluated: boolean` on `ChoiceOutcome`
|
||||
|
||||
Adds one boolean per (set, course) cell — negligible overhead. Cleaner than a sentinel value (e.g., `ceilingCount = -1`) that consumers might forget to handle and would need to be filtered everywhere ceilingCount is read. Boolean has obvious semantics in the UI ("not yet known" vs "known, value is 0").
|
||||
|
||||
**Alternative considered:** Omit cells entirely until evaluated. Rejected — the UI needs the courseName to render the row; restructuring to fetch course names from `coursesBySet` would push more responsibility into the renderer for no clear win.
|
||||
|
||||
### Per-set "Recommended" derivation in UI, not in worker
|
||||
|
||||
The recommended choice is a function of `analysis.choices` and the comparator. Computing in the UI keeps the worker protocol simple (no new field), avoids a duplicate computation, and lets the UI re-render cheaply on each choiceUpdate.
|
||||
|
||||
The comparator: `(ceilingCount desc, priorityScore desc)`. Same as the top-K. The "Recommended" course in a set is the one whose ceiling best matches the user's overall objective.
|
||||
|
||||
### Throttled `progress` event (≈100ms)
|
||||
|
||||
Without throttling, the worker would emit a progress event per iteration — 50,000 events × 1ms = 50s of message overhead. With ~100ms throttling: ~500 events per search, each tiny. Implementation: track `lastProgressEmit` timestamp; emit if `Date.now() - lastProgressEmit >= 100`.
|
||||
|
||||
**Alternative considered:** No progress events; rely on `topKUpdate` for activity signal. Rejected — top-K updates fire only when something changes; long stretches of "exploring duplicates" would look like a frozen UI.
|
||||
|
||||
### Mode-aware comparator (emerged during implementation)
|
||||
|
||||
After dropping saturation, exhaustive search surfaces 3-spec non-HCR plans that beat 2-spec HCR plans on the original (count, priorityScore) comparator. This conflicted with v1.3.0 spec scenarios that asserted HCR appears at `topK[0]` in priority-order mode with HCR ranked first. Resolution: make both the top-K and per-cell ceiling comparators mode-dependent.
|
||||
|
||||
- `priority-order` mode: `(priorityScore desc, count desc, key asc)` — surfaces the user's top-priority spec even when higher-count alternatives exist
|
||||
- `maximize-count` mode: `(count desc, priorityScore desc, key asc)` — surfaces the maximum number of specs achievable
|
||||
|
||||
Both comparators share the deterministic `assignmentKey` tiebreaker for streaming stability. The CourseSelection "Recommended" badge uses the same mode-dependent rule so cell recommendations align with the top-K ranking.
|
||||
|
||||
**Alternative considered:** Keep the count-first comparator and let exhaustive search reveal high-count alternatives. Rejected — contradicts user-stated intent ("HCR top priority should surface") and breaks v1.3.0 spec scenarios.
|
||||
|
||||
### `MAX_TREE_ITERATIONS = 100,000`
|
||||
|
||||
Empirically, 8-open-set worst case is ~50K leaves. 100K provides 2× headroom. Larger scenarios (10+ open sets) would still be capped, with `partial: true` displayed. The fallback to "empty choices" already exists for `openSetIds.length > MAX_OPEN_SETS_FOR_ENUMERATION` (= 9), so this cap rarely fires in practice.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **50s search feels slow** → Progress UI + streamed top-K make it feel active; user can adopt a plan partway through if they like what's shown
|
||||
- **Worker CPU usage during search** → Acceptable; runs in a worker thread, doesn't block UI; user can change pins to abort and restart
|
||||
- **Throttled progress means iteration count "jumps"** → Cosmetic only; UI doesn't depend on monotonic small steps
|
||||
- **`evaluated: false` initial state for every cell** → Slightly verbose payloads; choiceUpdate already sends the full set's choices array, so the change is one boolean field per cell (negligible)
|
||||
- **Mode switch mid-search** → Current behavior already terminates and restarts the worker on any pin/ranking/mode change; unchanged
|
||||
- **Tests need amendments** → Saturation tests removed (3 tests); exhaustion test added; mode-ordering test added; per-cell evaluated transition test added
|
||||
|
||||
## Migration Plan
|
||||
|
||||
Single-PR change. No data migration. Steps:
|
||||
|
||||
1. Algorithm + worker + state changes; tests updated
|
||||
2. UI updates: per-cell evaluated rendering, per-set spinner, global progress, recommended badge
|
||||
3. Browser-verify both modes against the v1.3.0 reproduction scenario; confirm exhaustive search completes and all cells are populated
|
||||
4. Bump version (`1.3.1`); CHANGELOG entry; ship
|
||||
|
||||
Rollback: revert the change; v1.3.0 behavior restored. No persistent state to migrate.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- **Recommended badge for ties** — if two choices in a set have identical `(count, priorityScore)`, currently the comparator's deterministic tiebreaker (assignmentKey) picks one. UI shows just one Recommended. Acceptable for v1; could be revisited if confusing.
|
||||
- **Should "Recommended" still show before search completes** — derived from current ceilings, so it updates as the search streams. Possibly confusing if the recommendation flips mid-search. Initial behavior: show as soon as any choice has `evaluated: true`; let it update with the stream.
|
||||
- **Future: progressive `partial` flag during search** — out of scope. Today, `partial` only matters at the cap, which fires rarely.
|
||||
@@ -0,0 +1,42 @@
|
||||
## Why
|
||||
|
||||
Real-world testing of v1.3.0 surfaced two related defects in the new decision-tree streaming search:
|
||||
|
||||
1. **Many per-set choices show "0 specs"** — the saturation termination (top-K stable for 500 iterations) fires after exploring only the heuristic-favored part of the tree. Courses in early sets that don't qualify for the priority target are never the chosen course in any evaluated leaf, so their ceilings remain at the initial `{count: 0, specs: []}` and render as a misleading "0 specs".
|
||||
2. **Top plans are not exhaustive in maximize-count mode** — same root cause: saturation accepts "no improvement" too eagerly, since many leaves yield the same already-found outcome class. Higher-count plans (e.g., `[BNK, CRF, FIN]` triples) that exist deeper in the search are never reached.
|
||||
|
||||
Both behaviors mislead the user: the per-set table claims a course leads to no specs when in fact it was never evaluated, and the Top Plans panel hides plans that genuinely exist. The fix is to drop the early-termination heuristic and run an exhaustive search, with mode-dependent enumeration ordering so the most-likely-good outcomes still appear early in the stream.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Remove `SATURATION_LIMIT` early-termination. Search runs the full cartesian product unless `MAX_TREE_ITERATIONS` (raised to 100,000 as safety cap) fires.
|
||||
- Add **mode-dependent child ordering** at every DFS level:
|
||||
- `priority-order` mode: keep the existing `priorityTarget`-qualifying-first heuristic.
|
||||
- `maximize-count` mode: new heuristic — order children by descending count of qualifications they hold for *reachable* specs (specs whose upper bound ≥ 9). "Generalist" courses like Climate Finance (BNK/CRF/FIN/FIM/GLB/SBI) come before specialist courses, surfacing high-count outcomes early.
|
||||
- Distinguish **unevaluated** cells from **evaluated, zero-spec** cells in `ChoiceOutcome`. New field `evaluated: boolean` (default `false`, set `true` on first leaf containing the (set, course) pair). UI renders unevaluated cells with a subtle searching indicator, not "0 specs".
|
||||
- Add **per-set progress indicator** — a small spinner next to the set name shown when `loading` is true and any choice in that set is still unevaluated; clears when every choice has been evaluated or when search completes.
|
||||
- Add **global progress indicator** in the Top Plans panel — `"Searching… N / Total explored"` with running counts, then `"Search complete · N explored"` when done. If `partial: true`, show `"Search incomplete · cap hit at N"`.
|
||||
- Add **"Recommended" marker** per set — the choice with the best `(ceilingCount, priorityScore)` per the same comparator the top-K uses; rendered as a small badge on the recommended row. Derived in the UI from `analysis.choices` (worker protocol unchanged on this front).
|
||||
- Worker emits a new `progress` event throttled to ~100ms intervals, carrying `{ iterations, iterationsTotal }`. Avoids per-iteration message flood while keeping the UI responsive.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
_None — this extends the existing optimization engine._
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `optimization-engine`: drop saturation termination requirement; require exhaustive search up to a safety cap; add mode-dependent ordering, evaluated/unevaluated cell state, per-set + global progress events, and a per-set recommended-choice derivation.
|
||||
|
||||
## Impact
|
||||
|
||||
- `app/src/solver/decisionTree.ts` — drop `SATURATION_LIMIT`; raise `MAX_TREE_ITERATIONS` to 100,000; add `reorderByReachableQualCount` helper for maximize-count mode; gate the chosen reorder strategy by `mode`; add `evaluated: boolean` to `ChoiceOutcome` and set it on first leaf containing the pair; emit throttled `progress` events; remove saturation logic
|
||||
- `app/src/workers/decisionTree.worker.ts` — add `progress` event type to the tagged union; throttle progress emission
|
||||
- `app/src/state/appState.ts` — track `searchProgress: { iterations, iterationsTotal } | null` slice; consume `progress` events
|
||||
- `app/src/components/TopPlans.tsx` — render global progress text in the header
|
||||
- `app/src/components/CourseSelection.tsx` — per-set spinner (next to set name); per-cell unevaluated rendering (skeleton/dot, not "0 specs"); "Recommended" badge on the best choice per set
|
||||
- `app/src/solver/__tests__/searchDecisionTree.test.ts` — remove saturation tests; add exhaustion test (asserts every (set, course) cell has `evaluated: true` after completion); add mode-dependent ordering test (maximize-count chooses generalist courses first); add unevaluated→evaluated transition test
|
||||
- `app/vite.config.ts` — bump to `1.3.1` (or `1.4.0` if user wants minor; default patch)
|
||||
- `CHANGELOG.md` — release entry
|
||||
- No data file changes; no schema migration
|
||||
@@ -0,0 +1,92 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Mode-dependent enumeration ordering
|
||||
The decision-tree search SHALL select its DFS child-ordering heuristic based on the optimization mode. In `priority-order` mode, children at each level SHALL be ordered with `priorityTarget`-qualifying courses first (existing behavior). In `maximize-count` mode, children SHALL be ordered by the descending count of their qualifications for *reachable* specializations (specializations whose upper-bound credit potential meets the credit threshold).
|
||||
|
||||
#### Scenario: maximize-count orders generalist courses first
|
||||
- **WHEN** maximize-count mode is selected and Fall Set 3 contains both `fall3-climate-finance` (qualifies for BNK/CRF/FIN/FIM/GLB/SBI — 6 reachable specs) and `fall3-emerging-tech` (qualifies for BRM/EMT/ENT/MTO/STR — 5)
|
||||
- **THEN** the DFS visits combinations including `fall3-climate-finance` before combinations including `fall3-emerging-tech`
|
||||
|
||||
#### Scenario: priority-order ordering is unchanged
|
||||
- **WHEN** priority-order mode is selected with HCR ranked first
|
||||
- **THEN** courses qualifying for HCR are tried before courses that do not (existing target-first behavior)
|
||||
|
||||
### Requirement: Cells distinguish unevaluated from evaluated-zero
|
||||
Each `ChoiceOutcome` SHALL carry an `evaluated: boolean` field. The field SHALL initialize to `false`. The field SHALL be set to `true` upon the first leaf evaluation that includes the corresponding `(setId, courseId)` pair. The UI SHALL render unevaluated cells with a visible "still searching" indicator distinct from cells that are evaluated and achieve zero specializations.
|
||||
|
||||
#### Scenario: New cell starts unevaluated
|
||||
- **WHEN** the search begins
|
||||
- **THEN** every choice in every set has `evaluated: false`
|
||||
|
||||
#### Scenario: First leaf marks the cell evaluated
|
||||
- **WHEN** any leaf containing `(spr3, spr3-analytics-ml)` has been evaluated
|
||||
- **THEN** the cell for `spr3-analytics-ml` has `evaluated: true`
|
||||
|
||||
#### Scenario: UI distinguishes unevaluated from evaluated-zero
|
||||
- **WHEN** a cell has `evaluated: false`
|
||||
- **THEN** the UI renders a "searching" indicator (not "0 specs")
|
||||
- **AND WHEN** a cell has `evaluated: true` and `ceilingSpecs.length === 0`
|
||||
- **THEN** the UI renders "0 specs" in muted styling
|
||||
|
||||
### Requirement: Per-set and global progress indication
|
||||
The decision-tree worker SHALL emit a `progress` event carrying `{ iterations, iterationsTotal }` at most once every 100 milliseconds during an active search. The UI SHALL display global search progress in the Top Plans panel header and SHALL display a per-set indicator next to each elective set's name while that set has at least one unevaluated choice and the search is still running.
|
||||
|
||||
#### Scenario: Progress events emitted at throttled rate
|
||||
- **WHEN** the search is running
|
||||
- **THEN** the worker emits at most one `progress` event per 100ms
|
||||
|
||||
#### Scenario: Global progress visible in header
|
||||
- **WHEN** the search is running and 15234 of 49152 leaves have been evaluated
|
||||
- **THEN** the Top Plans header shows progress text such as `Searching… 15234 / 49152 explored`
|
||||
|
||||
#### Scenario: Per-set indicator shown while choices are unevaluated
|
||||
- **WHEN** the search is running and Spring Set 1 has at least one choice with `evaluated: false`
|
||||
- **THEN** a spinner or activity indicator appears next to the Spring Set 1 heading
|
||||
- **AND WHEN** every choice in Spring Set 1 has `evaluated: true` (or the search completes)
|
||||
- **THEN** the indicator clears
|
||||
|
||||
### Requirement: Recommended choice per set
|
||||
For each open elective set, the UI SHALL identify and visually mark the choice with the best `(ceilingCount desc, priorityScore desc)` ordering as the "Recommended" choice. The marker SHALL be visible only when at least one choice in the set has `evaluated: true`. The recommendation SHALL update progressively as the search streams better outcomes for that set's choices.
|
||||
|
||||
#### Scenario: Recommended marker uses same comparator as top-K
|
||||
- **WHEN** Spring Set 3 has choices with ceilings `[HCR, BNK]` (count=2, score=29) and `[FIN, MTO]` (count=2, score=22)
|
||||
- **THEN** the choice with `[HCR, BNK]` is marked Recommended
|
||||
|
||||
#### Scenario: Higher count beats higher priority for recommendation
|
||||
- **WHEN** one choice has ceiling count 3 and another has ceiling count 2 with higher priority score
|
||||
- **THEN** the count-3 choice is Recommended
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Bounded search with saturation termination
|
||||
The decision-tree search SHALL terminate when the iteration count exceeds `MAX_TREE_ITERATIONS` (default 100,000). When this cap terminates the search before the cartesian product has been fully enumerated, the result SHALL include `partial: true`. The search SHALL otherwise enumerate every leaf in the cartesian product of open-set courses (the saturation-limit termination is removed).
|
||||
|
||||
#### Scenario: Search exhausts the cartesian product when within the cap
|
||||
- **WHEN** the open-set cartesian product is smaller than `MAX_TREE_ITERATIONS`
|
||||
- **THEN** the search evaluates every leaf, every cell ends with `evaluated: true`, and `partial` is `false`
|
||||
|
||||
#### Scenario: Search returns partial when cap is hit
|
||||
- **WHEN** the cartesian product exceeds `MAX_TREE_ITERATIONS`
|
||||
- **THEN** the search stops at the cap, sets `partial: true`, and returns the best top-K and ceilings found so far
|
||||
|
||||
### Requirement: Decision-tree worker protocol
|
||||
The decision-tree worker SHALL accept a `WorkerRequest` that includes optional `topK` (default 10). It SHALL emit a tagged-union `WorkerResponse` stream with four event types: `topKUpdate` (when the ranked top-K list changes), `choiceUpdate` (when a per-set ceiling cell changes), `progress` (throttled to 100ms intervals during search, carrying iteration counters), and `allComplete` (when the search terminates, carrying both final top-K and final per-set analyses, plus a `partial` flag).
|
||||
|
||||
#### Scenario: Worker emits progress events
|
||||
- **WHEN** the search runs for more than 100ms
|
||||
- **THEN** the worker emits at least one `progress` event with `iterations` and `iterationsTotal`
|
||||
|
||||
#### Scenario: Worker emits final allComplete event with partial flag
|
||||
- **WHEN** the search terminates
|
||||
- **THEN** the worker emits `{ type: 'allComplete', topK, setAnalyses, partial }`
|
||||
- **AND** `partial` is `true` only if the iteration cap fired
|
||||
|
||||
#### Scenario: Worker emits per-cell choice updates
|
||||
- **WHEN** a single combination causes a ceiling change for one course in one set
|
||||
- **THEN** the worker emits one `choiceUpdate` event identifying that set
|
||||
|
||||
## REMOVED Requirements
|
||||
|
||||
### Requirement: Saturation-limit early termination
|
||||
**Reason**: The saturation criterion (`top-K stable for SATURATION_LIMIT iterations`) terminates the search before many `(set, course)` pairs have been evaluated and before the search reaches deeper combinations that yield higher-count outcomes. The user-visible result is "0 specs" labels on un-evaluated cells and missing high-count plans in the top-K. Replaced by exhaustive enumeration up to the iteration cap.
|
||||
**Migration**: No consumer migration needed. The `searchDecisionTree` function signature is unchanged; behavior changes from "may stop early" to "always exhausts (within cap)". The `partial: true` flag remains as the only signal that the result may be incomplete.
|
||||
@@ -0,0 +1,75 @@
|
||||
## 1. Drop saturation, raise cap
|
||||
|
||||
- [x] 1.1 In `app/src/solver/decisionTree.ts`, remove the `SATURATION_LIMIT` constant and all references to `iterationsSinceTopKChange` (declaration, increment, reset, comparison)
|
||||
- [x] 1.2 Raise `MAX_TREE_ITERATIONS` to `100_000`
|
||||
- [x] 1.3 Confirm the only termination paths are now: (a) iteration cap fires (sets `partial = true`), or (b) DFS exhausts the cartesian product
|
||||
|
||||
## 2. Mode-dependent ordering
|
||||
|
||||
- [x] 2.1 Add `reorderByReachableQualCount(setId, upperBounds, excludedCourseIds): Course[]` — returns courses sorted by descending count of qualifications for specs whose `upperBounds[specId] >= 9`. Stable sort; ties keep declaration order; cancelled courses excluded.
|
||||
- [x] 2.2 In `searchDecisionTree`, replace the `orderedCoursesPerSet` initialization to gate by `mode`: `priority-order` keeps `reorderForTarget(setId, priorityTarget, ...)`; `maximize-count` uses `reorderByReachableQualCount(setId, upperBounds, ...)`
|
||||
- [x] 2.3 Unit test: assert that in maximize-count mode, the first DFS leaf for the user's reproduction scenario contains `fall3-climate-finance` (the most-generalist Fall Set 3 course) before `fall3-emerging-tech`
|
||||
|
||||
## 3. Evaluated/unevaluated cell state
|
||||
|
||||
- [x] 3.1 Add `evaluated: boolean` field to `ChoiceOutcome` (`app/src/solver/decisionTree.ts`); initialize to `false` in the per-set analysis init loop
|
||||
- [x] 3.2 In `evaluateLeaf`, after running the optimizer and computing `result`, set `choice.evaluated = true` for every `(setId, courseId)` in the leaf's assignments BEFORE the comparison. (The cell is "evaluated" the moment a leaf containing it is run, regardless of whether the result improves the ceiling.)
|
||||
- [x] 3.3 Confirm the `choiceUpdate` callback fires whenever either `evaluated` flips to `true` or the ceiling improves — so the UI sees the transition. Update the existing condition to fire on both
|
||||
- [x] 3.4 Unit test: after one leaf evaluation containing `(spr3, spr3-analytics-ml)`, that cell has `evaluated: true`; cells in other sets that aren't in the leaf assignment remain `evaluated: false`
|
||||
|
||||
## 4. Throttled progress events
|
||||
|
||||
- [x] 4.1 Add `progress` to the `WorkerResponse` tagged union in `app/src/workers/decisionTree.worker.ts`: `{ type: 'progress'; iterations: number; iterationsTotal: number }`
|
||||
- [x] 4.2 Compute `iterationsTotal` in `searchDecisionTree` before DFS starts: product of `orderedCoursesPerSet[setId].length` over all `openSetIds`. Pass it through to the progress callback
|
||||
- [x] 4.3 Add `onProgress?: (iterations: number, iterationsTotal: number) => void` to `SearchCallbacks`. Track `lastProgressEmit: number` (default 0); call `onProgress` from inside `evaluateLeaf` when `Date.now() - lastProgressEmit >= 100`
|
||||
- [x] 4.4 In the worker, wire `onProgress` to `postMessage({ type: 'progress', iterations, iterationsTotal })`
|
||||
|
||||
## 5. App state wiring
|
||||
|
||||
- [x] 5.1 In `app/src/state/appState.ts`, add a `searchProgress: { iterations: number; iterationsTotal: number } | null` slice (default `null`); update on `progress` events; reset to `null` on new search start and on `allComplete`
|
||||
- [x] 5.2 Export `searchProgress` from `useAppState` alongside `topPlans`/`topPlansPartial`
|
||||
- [x] 5.3 The `choiceUpdate` handler already updates the per-set map; verify the new `evaluated` field flows through unchanged (no code change needed; `analysis` is forwarded as-is)
|
||||
|
||||
## 6. Top Plans header (global progress)
|
||||
|
||||
- [x] 6.1 In `app/src/components/TopPlans.tsx`, accept `searchProgress` and `loading` props and render in the header:
|
||||
- while `loading && searchProgress`: `Searching… {iterations.toLocaleString()} / {iterationsTotal.toLocaleString()} explored`
|
||||
- after complete (`!loading && !partial`): `Search complete · {totalEvaluated} explored`
|
||||
- after complete with `partial`: `Search incomplete · cap hit at {MAX_TREE_ITERATIONS}` (existing partial caption replaced/extended)
|
||||
- [x] 6.2 Pass `searchProgress` from App.tsx into `<TopPlans>`
|
||||
|
||||
## 7. CourseSelection per-set + per-cell rendering
|
||||
|
||||
- [x] 7.1 In `app/src/components/CourseSelection.tsx`, in the `ElectiveSet` heading area: render a small spinner/dot next to the set name when `loading === true` AND `analysis?.choices.some(c => !c.evaluated)`. The existing "high impact" badge stays
|
||||
- [x] 7.2 Replace the per-cell ceiling render branch:
|
||||
- `!evaluated`: render a faint "·" or pulsing dot (use the existing skeleton pattern restyled, or a small `…` glyph) instead of "0 specs"
|
||||
- `evaluated && ceilingCount === 0`: render "0 specs" in muted grey
|
||||
- `evaluated && ceilingCount > 0`: render existing colored "N specs (LIST)" treatment
|
||||
- [x] 7.3 Compute the recommended choice per set: pick the choice with the best `(ceilingCount desc, priorityScore desc)` — only consider choices with `evaluated === true`. Render a small `⭐ Recommended` badge on that row. Hide the badge if no choice is yet evaluated
|
||||
- [x] 7.4 The recommended derivation needs `priorityScore`; import `makePriorityScorer` from `app/src/solver/priority` and memoize per-render with `state.ranking`
|
||||
|
||||
## 8. Tests
|
||||
|
||||
- [x] 8.1 Remove the saturation early-termination test from `app/src/solver/__tests__/searchDecisionTree.test.ts` (the test that asserts iteration count stays well under cap when topK converges quickly — no longer applies)
|
||||
- [x] 8.2 Add an exhaustion test: small scenario (e.g., 2 open sets); after `searchDecisionTree` returns, every choice in every open set has `evaluated: true` and `partial === false`
|
||||
- [x] 8.3 Add a mode-dependent ordering test: in maximize-count mode for the user's reproduction scenario, the first leaf evaluated contains `fall3-climate-finance` (verify by capturing the first `onChoiceUpdate` event for `fall3` and inspecting the assignment)
|
||||
- [x] 8.4 Add an evaluated-flag transition test: assert all cells start `evaluated: false`; after one leaf evaluation, only cells with that leaf's assignments are `evaluated: true`
|
||||
- [x] 8.5 Update the streaming monotonicity test if needed (still valid in concept; just verify with new termination)
|
||||
- [x] 8.6 Add a progress-event throttling test: capture `onProgress` calls during a search; assert minimum interval >= 90ms between consecutive calls (small jitter tolerance)
|
||||
- [x] 8.7 Update the performance smoke test to allow longer time budget (e.g., 60 seconds) since the search is now exhaustive
|
||||
- [x] 8.8 Run full test suite; confirm all pass
|
||||
|
||||
## 9. Browser verification
|
||||
|
||||
- [x] 9.1 Start dev server; reproduce user's pin scenario (SP2/SP4/SP5/SE1, HCR first)
|
||||
- [x] 9.2 Switch to maximize-count mode; confirm Top Plans surfaces 3-spec plans (e.g., `[BNK, CRF, FIN]` triples) if any are feasible — if not feasible for this scenario, try a less-pinned scenario to confirm 3-spec plans CAN appear
|
||||
- [x] 9.3 Confirm per-set spinner appears next to "Spring Elective Set 1" while search runs and clears when complete
|
||||
- [x] 9.4 Confirm per-cell rendering shows "·" or similar for unevaluated cells, then transitions to "N specs" or "0 specs" as evaluation completes
|
||||
- [x] 9.5 Confirm `⭐ Recommended` appears on one course per set after at least one cell in that set is evaluated; verify it matches the best `(count, priorityScore)`
|
||||
- [x] 9.6 Confirm Top Plans header shows progress text (`Searching… N / Total`) during search and `Search complete` after
|
||||
- [x] 9.7 Adopt-plan still works correctly; no regression
|
||||
|
||||
## 10. Version + changelog
|
||||
|
||||
- [x] 10.1 Bump `__APP_VERSION__` to `1.3.1` and `__APP_VERSION_DATE__` in `app/vite.config.ts`
|
||||
- [x] 10.2 Add `## v1.3.1` entry to `CHANGELOG.md` describing: exhaustive search (drop saturation), mode-dependent ordering, evaluated/unevaluated cell distinction, per-set + global progress indicators, Recommended marker
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-05-09
|
||||
@@ -0,0 +1,3 @@
|
||||
# decision-tree-leaf-cache
|
||||
|
||||
Cache decision-tree leaf outcomes (achievedSpecs + priorityScore) keyed by assignment, so pin/unpin operations re-derive top-K and per-set ceilings instantly without re-running the worker. Cache invalidates on ranking or mode change, or when size exceeds 500k entries. Worker accepts a skipKeys set to avoid recomputing cached leaves on partial-hit re-runs (unpin).
|
||||
@@ -0,0 +1,116 @@
|
||||
## Context
|
||||
|
||||
v1.3.1 ships exhaustive decision-tree search. Per-cell ceilings and top-K plans are correct, but every pin/unpin click triggers a full re-search (~7s for an 8-open-set scenario). The compute is wasteful: leaf outcomes are pure functions of `(courseAssignments, ranking, mode)`. A leaf evaluated once stays correct as long as ranking and mode don't change.
|
||||
|
||||
Today the search effect in `useAppState` (`appState.ts:124-191`) lists `[selectedCourseIds, openSetIds, ranking, mode, excludedCourseIds]` as deps. Any change kills the running worker and starts a fresh search after a 300ms debounce.
|
||||
|
||||
This change adds an in-memory cache of leaf outcomes keyed by the existing `assignmentKey`. Pin operations become 100% cache hits (no worker). Unpin operations get a partial hit (cached subset rendered immediately, missing leaves computed in the background).
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Pin clicks produce instant top-K + per-cell updates with no spinner
|
||||
- Unpin clicks render the cached subset immediately as a lower bound, then improve as the worker streams new leaves
|
||||
- "Adopt plan" (which fires multiple pin actions in quick succession) feels instant
|
||||
- Cache memory is bounded (~80 MB worst case at the 500k cap)
|
||||
|
||||
**Non-Goals:**
|
||||
- Persisting the cache across page reloads (no localStorage; recompute on first use after reload)
|
||||
- Caching across ranking or mode changes
|
||||
- Re-architecting the optimizer or worker protocol beyond the new `skipKeys` field
|
||||
- A "tree of caches" keyed by ranking — keep one cache, invalidate on ranking change
|
||||
|
||||
## Decisions
|
||||
|
||||
### Cache key = `assignmentKey` only; cache scope = `(ranking, mode)`
|
||||
|
||||
`assignmentKey` already exists (`decisionTree.ts:79-84`) and is the deterministic stringification used by the comparator's tiebreaker. Reusing it avoids a parallel hashing scheme. Cache scope is bound to the current `(ranking, mode)` pair: when either changes, the cache is wiped and rebuilt from scratch on the next search.
|
||||
|
||||
**Alternative considered:** Keep multiple caches, one per `(ranking, mode)` pair the user has visited. Rejected — extra complexity for a workflow where ranking/mode changes are infrequent compared to pin clicks. If profiling later shows ranking-toggle becoming a hot path, can add then.
|
||||
|
||||
### Immediate render on partial hit (unpin), then stream improvements
|
||||
|
||||
When the user unpins a set, the new openSetIds product is larger than the cached subset. Today's UX would block on a fresh worker. Instead:
|
||||
|
||||
1. Filter the cache against the new pinned + excluded state. Derive top-K and per-set ceilings from the filtered leaves. Render immediately.
|
||||
2. Compute missing leaves count. If non-zero, spawn the worker with `skipKeys = cache.keys()`. The worker DFS visits every leaf in the new search space but skips the optimizer call for keys already cached.
|
||||
3. As the worker streams new leaves (existing `topKUpdate` / `choiceUpdate` events plus a final `allComplete`), insert each into the cache and re-derive top-K/ceilings via the existing streaming path.
|
||||
|
||||
The cached subset is a strict lower bound on the true result: top-K from cache is a valid (possibly incomplete) subset of the true top-K; per-set ceilings from cache are ≤ the true ceilings. Streaming improvements are monotonically non-decreasing under the existing comparator.
|
||||
|
||||
**Alternative considered:** Show a spinner and wait for the search to complete before rendering. Rejected — would feel like a regression after pin became instant. The streaming UI already handles monotonic improvement gracefully.
|
||||
|
||||
### Worker contract: `skipKeys: string[]`
|
||||
|
||||
The simplest extension. Main thread serializes `cache.keys()` to an array; worker reconstructs the Set on the receiving end. For 65k cached keys × ~30 chars each = ~2 MB transfer per worker spawn. structured-cloned in tens of milliseconds. Acceptable.
|
||||
|
||||
**Alternative considered:** Persistent worker that holds the cache internally. Rejected — adds lifecycle complexity (when to terminate, how to abort in-flight work, race conditions on cache writes). The fresh-worker model already exists and the transfer cost is bearable.
|
||||
|
||||
### `evaluateLeaf` short-circuit semantics
|
||||
|
||||
When a leaf's key is in `skipKeys`:
|
||||
- Increment `iterations` (so progress reflects total leaves visited, not just newly-computed)
|
||||
- Skip the optimizer call, skip `topK.tryInsert`, skip `choiceUpdate` emit
|
||||
- Skip `evaluated` flag updates (those cells were already marked from the cached results on the main thread)
|
||||
- Still emit `progress` events at the throttled rate
|
||||
|
||||
Rationale: the user-visible "iterations explored" should reflect tree size, not just delta. Otherwise an unpin would show "Searching… 100,000/200,000" jumping from 100k mid-search, which is confusing.
|
||||
|
||||
**Alternative considered:** Don't increment iterations for skipped leaves. Rejected — the percentage in the progress bar would be misleading.
|
||||
|
||||
### `deriveFromLeaves` extracted as a pure helper
|
||||
|
||||
To produce top-K and per-set ceilings from a leaf collection on the main thread, factor out the relevant logic from `searchDecisionTree`. The helper takes `(leaves, K, mode, ranking, openSetIds, excludedCourseIds)` and returns `{ topK: PlanOutcome[], setAnalyses: SetAnalysis[] }`.
|
||||
|
||||
The worker uses the same helper at `allComplete` to produce its final emission. The main thread uses it for the immediate-render path.
|
||||
|
||||
**Alternative considered:** Maintain top-K and per-set ceilings incrementally in the cache structure itself. Rejected — too coupled; recomputing from leaves is O(cache.size) which is fast (linear scan + small comparator work).
|
||||
|
||||
### Cache cap = 500k leaves with full clear on overflow
|
||||
|
||||
500k × ~300 bytes ≈ 150 MB. Comfortable on desktop, snug on mobile. Tripping the cap requires extreme exploration paths (≥10 open sets + repeated cycles); typical sessions stay under 100k.
|
||||
|
||||
When the cap fires, clear the entire cache. Simplest possible policy. Subsequent searches behave as v1.3.1 (full recompute).
|
||||
|
||||
**Alternative considered:** LRU eviction. Rejected for v1 — adds bookkeeping overhead per insert; benefit is marginal given cap is rarely hit. Add later if profiling shows churn.
|
||||
|
||||
### Cache invalidation events
|
||||
|
||||
| Event | Cache action |
|
||||
|---|---|
|
||||
| Pin a course | Keep cache; filter |
|
||||
| Unpin a course | Keep cache; filter (partial hit) |
|
||||
| Adopt plan | Keep cache; filter |
|
||||
| Ranking re-order | Clear cache; re-search from scratch |
|
||||
| Mode toggle | Clear cache; re-search from scratch |
|
||||
| Cancellation toggle (data file edit) | Clear cache (data version changed) |
|
||||
| Cache size exceeds 500k | Clear cache |
|
||||
|
||||
The ranking/mode/cancellation changes are uncommon compared to pin clicks, so the simplification is worth it.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **Memory footprint** → Mitigation: 500k cap clears the cache when exceeded; typical sessions stay well under
|
||||
- **Worker transfer of `skipKeys`** → ~2 MB per worker spawn at 65k cached keys; acceptable, measure and revisit if it becomes painful at scale
|
||||
- **Cached subset can briefly disagree with worker's final result** during streaming → Existing streaming UI handles monotonic improvement; the only visible effect is some cells starting at a lower count and improving as the worker fills in
|
||||
- **Iteration counter semantics** during skip-mode runs → Counts total leaves visited (cached + new). Decision documented above; clear in the proposal scenarios so users understand "Searching… 50,000/200,000" early in an unpin run
|
||||
- **First-time experience unchanged** → Empty cache means full search; no improvement on initial page load. Subsequent operations are where the win happens
|
||||
|
||||
## Migration Plan
|
||||
|
||||
Single-PR change. No data migration. No persistent state to migrate.
|
||||
|
||||
1. Implement `skipKeys` plumbing through `searchDecisionTree` and worker
|
||||
2. Extract `deriveFromLeaves` helper
|
||||
3. Add cache to `useAppState`; restructure the worker effect to filter-then-spawn
|
||||
4. Tests for skip-keys correctness, derive helper parity, cache-cap eviction, ranking/mode invalidation
|
||||
5. Browser verify: pin/unpin behave instantly after the first search
|
||||
6. Bump version (`1.3.2`); CHANGELOG entry; ship
|
||||
|
||||
Rollback: revert. v1.3.1 behavior restored — every pin/unpin runs a fresh worker.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Memory measurement on representative scenarios (8-set typical, 10-set extreme) — instrument once for the changelog notes
|
||||
- Whether `skipKeys` transfer becomes a bottleneck at very large cache sizes — defer; haven't observed it in typical use
|
||||
- Whether to expose a debug toggle to disable the cache (useful for A/B feel testing) — defer; can add as a query param if needed
|
||||
@@ -0,0 +1,36 @@
|
||||
## Why
|
||||
|
||||
After v1.3.1 made the decision-tree search exhaustive, every pin/unpin click triggers a fresh full search (~7s in the worker for the user's typical 8-open-set scenario). That re-run is wasteful: a leaf's outcome (`achievedSpecs`, `priorityScore`) depends only on its 12-course assignment plus ranking and mode. Pinning or unpinning a course never changes the value of any leaf that has already been evaluated; it only changes which leaves are reachable in the current search.
|
||||
|
||||
We should cache evaluated leaves and re-derive the top-K and per-set ceilings from the cache when pin/unpin operations leave ranking and mode untouched. Pin operations become 100% cache hits (instant). Unpin operations get a partial cache hit — show the cached subset immediately as a lower-bound result, then run the worker to fill in only the leaves that haven't been evaluated yet.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Add a main-thread leaf cache: `Map<assignmentKey, PlanOutcome>` keyed by the existing `assignmentKey` (sorted setId:courseId join). Cache instance is held in a ref on `useAppState`.
|
||||
- Cache invalidation triggers: any change to `state.ranking`, `state.mode`, or the cancellation list (data version). Pin, unpin, and adopt-plan operations leave the cache intact.
|
||||
- New effect logic in `useAppState`: when the search-effect dependencies change, first FILTER the existing cache against the new pinned/excluded state and derive top-K + per-set ceilings from the filtered subset, rendering immediately. Then check whether the filtered count equals the expected cartesian-product size; if not, spawn a worker to compute the missing leaves only.
|
||||
- Add an optional `skipKeys: Set<string>` parameter to `searchDecisionTree` and to the worker's `WorkerRequest`. In `evaluateLeaf`, leaves whose `assignmentKey` is in `skipKeys` are skipped entirely — no optimizer call, no callback emit, no iteration counted toward progress (or counted but not evaluated; design choice in tasks).
|
||||
- The worker streams new leaves the same way it streams `topKUpdate` / `choiceUpdate` events today; main thread inserts each new leaf into the cache as it arrives.
|
||||
- Soft cap: if the cache exceeds **500,000** entries, clear it entirely. Subsequent searches recompute from scratch. The cap exists only to bound worst-case memory; typical exploration paths never approach it.
|
||||
- "Searching" UI indicators (per-set spinner, global progress bar) only appear when the worker actually runs (i.e., not on full-cache-hit pin clicks).
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
_None._
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `optimization-engine`: introduces leaf caching across search invocations, the `skipKeys` worker contract, the immediate-render-then-stream pipeline for partial cache hits, and the cache-cap eviction policy. The optimizer and LP feasibility checker are untouched.
|
||||
|
||||
## Impact
|
||||
|
||||
- `app/src/state/appState.ts` — add `leafCacheRef` (Map<string, PlanOutcome>); restructure the worker effect to filter cache, derive immediate state, and spawn the worker only for the delta. Reset cache on ranking/mode change. Apply the 500k cap.
|
||||
- `app/src/solver/decisionTree.ts` — add `skipKeys?: Set<string>` to `SearchCallbacks`/`searchDecisionTree` signature; in `evaluateLeaf` short-circuit when the leaf's assignmentKey is already in `skipKeys`. Export a `deriveFromLeaves(leaves, K, mode, ranking, openSets, excludedCourseIds)` helper that produces `{ topK, setAnalyses }` from a leaf collection, used both by the worker (final emit) and the main-thread filter path.
|
||||
- `app/src/workers/decisionTree.worker.ts` — accept `skipKeys?: string[]` in `WorkerRequest`; convert to `Set<string>` and pass to `searchDecisionTree`.
|
||||
- `app/src/solver/__tests__/searchDecisionTree.test.ts` — add tests: skipKeys correctly bypasses optimizer; `deriveFromLeaves` matches a fresh search's output when given the same leaves; cache filter pin/unpin idempotence (same final state regardless of pin order).
|
||||
- New unit test file (or extension of existing): cache-cap eviction, ranking/mode invalidation.
|
||||
- `app/vite.config.ts` — bump to `1.3.2`
|
||||
- `CHANGELOG.md` — release entry
|
||||
- No data-file changes
|
||||
@@ -0,0 +1,63 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Persistent leaf cache across pin and unpin operations
|
||||
The application SHALL maintain a main-thread cache of evaluated decision-tree leaves keyed by the leaf's `assignmentKey` (the deterministic sorted `setId:courseId` join already used as the comparator tiebreaker). The cache SHALL persist across pin, unpin, and adopt-plan operations as long as `state.ranking` and `state.mode` are unchanged. Each cache entry SHALL store the full `PlanOutcome` (`courseAssignments`, `achievedSpecs`, `priorityScore`).
|
||||
|
||||
#### Scenario: Pin operation hits cache fully
|
||||
- **WHEN** the user has completed a search with no pins on a small scenario, then pins a course
|
||||
- **THEN** the new top-K and per-set ceilings are derived entirely from the cache without spawning a worker
|
||||
- **AND** no "searching" indicators appear in the UI
|
||||
|
||||
#### Scenario: Cache survives consecutive pin clicks
|
||||
- **WHEN** the user pins multiple courses one after another (or via "Adopt plan")
|
||||
- **THEN** every pin produces an instant UI update sourced from the existing cache
|
||||
|
||||
#### Scenario: Unpin gets immediate cached subset and streams improvements
|
||||
- **WHEN** the user unpins a course after a search has populated the cache
|
||||
- **THEN** the UI immediately renders top-K and per-set ceilings derived from the cache subset matching the new state
|
||||
- **AND** a worker spawns to compute the missing leaves
|
||||
- **AND** as the worker streams new leaves, the UI's top-K and ceilings improve monotonically
|
||||
|
||||
### Requirement: `skipKeys` worker contract
|
||||
The worker request SHALL accept an optional `skipKeys: string[]` field. The worker SHALL convert this list to a `Set<string>` and pass it to `searchDecisionTree`. Inside `evaluateLeaf`, leaves whose `assignmentKey` is in `skipKeys` SHALL be skipped: the optimizer SHALL NOT be invoked, no `topKUpdate` or `choiceUpdate` event SHALL be emitted for them, and the leaf SHALL NOT mutate per-set `evaluated` flags. Skipped leaves SHALL still increment the iteration counter so that throttled `progress` events report the total tree size, not just the delta.
|
||||
|
||||
#### Scenario: Worker bypasses optimizer for cached leaves
|
||||
- **WHEN** the worker receives a request with `skipKeys` containing the keys of N cached leaves
|
||||
- **THEN** the worker performs at most `(iterationsTotal − N)` optimizer evaluations
|
||||
|
||||
#### Scenario: Progress reports total tree size
|
||||
- **WHEN** the worker is processing a request with `skipKeys` containing 50,000 keys out of an `iterationsTotal` of 200,000
|
||||
- **THEN** progress events include `iterations` counting up to 200,000 (not 150,000) so the displayed percentage reflects whole-tree progress
|
||||
|
||||
### Requirement: Cache invalidation on ranking, mode, or data change
|
||||
The leaf cache SHALL be cleared when `state.ranking` changes, when `state.mode` changes, or when the underlying course/specialization data is changed (e.g., a course is marked cancelled). Pin/unpin operations SHALL NOT trigger cache invalidation.
|
||||
|
||||
#### Scenario: Mode toggle clears cache
|
||||
- **WHEN** the user toggles between maximize-count and priority-order
|
||||
- **THEN** the cache is emptied and the next search runs as a full recomputation
|
||||
|
||||
#### Scenario: Ranking re-order clears cache
|
||||
- **WHEN** the user reorders the specialization ranking
|
||||
- **THEN** the cache is emptied and the next search runs as a full recomputation
|
||||
|
||||
#### Scenario: Pin does not clear cache
|
||||
- **WHEN** the user pins or unpins a course
|
||||
- **THEN** the cache retains all previously evaluated leaves
|
||||
|
||||
### Requirement: Cache size cap
|
||||
The leaf cache SHALL be cleared when its size exceeds 500,000 entries. Subsequent searches SHALL repopulate the cache from scratch.
|
||||
|
||||
#### Scenario: Cap clears cache when exceeded
|
||||
- **WHEN** the cache is at 500,000 entries and a new search would add at least one more entry
|
||||
- **THEN** the cache is emptied before the next entry is inserted, and the new search proceeds without `skipKeys`
|
||||
|
||||
### Requirement: `deriveFromLeaves` shared helper
|
||||
The decision-tree module SHALL export a pure function `deriveFromLeaves(leaves, K, mode, ranking, openSetIds, excludedCourseIds): { topK, setAnalyses }` that produces the top-K plan list and per-set ceiling table from a collection of leaf outcomes. This helper SHALL be used both by the worker at `allComplete` and by the main thread when rendering filtered cache results.
|
||||
|
||||
#### Scenario: Helper output matches a fresh search
|
||||
- **WHEN** `deriveFromLeaves` is called with the complete leaf set from a finished `searchDecisionTree` run
|
||||
- **THEN** the returned `topK` and `setAnalyses` match the values that the search itself returned (modulo deterministic tiebreaker stability)
|
||||
|
||||
#### Scenario: Helper output is correct for filtered subsets
|
||||
- **WHEN** `deriveFromLeaves` is called with a strict subset of cached leaves matching the user's current pinned/excluded state
|
||||
- **THEN** the returned top-K and ceilings reflect only those leaves and never reference courses outside the filter
|
||||
@@ -0,0 +1,61 @@
|
||||
## 1. Solver: skipKeys + deriveFromLeaves
|
||||
|
||||
- [x] 1.1 In `app/src/solver/decisionTree.ts`, extend `SearchCallbacks` with no new fields (callbacks unchanged); extend `searchDecisionTree` signature to accept `skipKeys?: Set<string>` as a new optional parameter
|
||||
- [x] 1.2 In `evaluateLeaf`, after computing `aKey = assignmentKey(accumulated)`, short-circuit when `skipKeys?.has(aKey)`: increment `iterations`, call `emitProgress()`, then return without invoking the optimizer or any callback. Per-set ceiling and `evaluated` flag updates are skipped — the main thread already has those values from the cached path
|
||||
- [x] 1.3 Implement `export function deriveFromLeaves(leaves: Iterable<PlanOutcome>, K: number, mode: OptimizationMode, ranking: string[], openSetIds: string[], excludedCourseIds?: Set<string>): { topK: PlanOutcome[]; setAnalyses: SetAnalysis[] }`
|
||||
- Initialize `setAnalyses` for every `setId` in `openSetIds` using the same per-mode reorder helpers (`reorderForTarget` / `reorderByReachableQualCount`)
|
||||
- For each leaf, run the same per-set ceiling-update loop already in `evaluateLeaf`, plus `topK.tryInsert`
|
||||
- Return the same shape `searchDecisionTree` returns minus `iterations`/`partial`
|
||||
- [x] 1.4 Refactor `searchDecisionTree` to call `deriveFromLeaves` for its own final emission (or keep the inline loop and ensure both paths produce identical output — must add a parity test either way)
|
||||
|
||||
## 2. Worker contract
|
||||
|
||||
- [x] 2.1 In `app/src/workers/decisionTree.worker.ts`, extend `WorkerRequest` with `skipKeys?: string[]`
|
||||
- [x] 2.2 In the message handler, convert `skipKeys` to `Set<string>` and pass it through to `searchDecisionTree`
|
||||
|
||||
## 3. App state: cache + filter pipeline
|
||||
|
||||
- [x] 3.1 In `app/src/state/appState.ts`, add `leafCacheRef = useRef<{ ranking: string[]; mode: OptimizationMode; leaves: Map<string, PlanOutcome> }>({ ranking: [], mode: 'maximize-count', leaves: new Map() })`
|
||||
- [x] 3.2 Add a helper `function shouldInvalidate(cache, ranking, mode): boolean` that returns true when ranking or mode has changed (use shallow equality on ranking via `JSON.stringify` or element-wise compare)
|
||||
- [x] 3.3 Add `function filterCacheToCurrentState(cache, pinnedCourses, excludedCourseIds, openSetIds): PlanOutcome[]` that returns leaves where (a) every pinned set's assignment matches the leaf, (b) no excluded courses appear in the leaf's assignments, and (c) the leaf's assignment keys are exactly `openSetIds` (filters out leaves cached under a different set partition)
|
||||
- [x] 3.4 Restructure the existing search effect:
|
||||
- On every effect run, check `shouldInvalidate(cacheRef.current, ranking, mode)`. If true, clear `cacheRef.current.leaves` and update `ranking` / `mode` fields
|
||||
- Compute `filtered = filterCacheToCurrentState(...)` and `expectedTotal = product over openSetIds of orderedCourses[setId].length` (use the same reorder helpers, mode-dependent)
|
||||
- Compute `{ topK, setAnalyses } = deriveFromLeaves(filtered, ...)` and call all the existing `setX` setters to render immediately
|
||||
- Set `searchProgress = { iterations: filtered.length, iterationsTotal: expectedTotal }`
|
||||
- If `filtered.length === expectedTotal`: set `treeLoading=false`, return (no worker)
|
||||
- Else: set `treeLoading=true`, debounce, spawn worker as today, BUT include `skipKeys: [...cacheRef.current.leaves.keys()]` in the request
|
||||
- [x] 3.5 In the worker `onmessage` handler:
|
||||
- On `topKUpdate`: insert any newly-seen leaves into the cache (worker emits leaves implicitly via topK; we may need a richer event — see 3.6)
|
||||
- On `choiceUpdate`: insert any newly-seen leaves implicitly is hard; better to add an explicit leaf-emit event
|
||||
- [x] 3.6 Add a new `WorkerResponse` event type `{ type: 'leafEvaluated'; leaf: PlanOutcome }` emitted from inside `evaluateLeaf` when the leaf is NOT skipped. This is what feeds the main-thread cache. Throttling: emit each leaf as its own event (small payload, ~300 bytes); existing topKUpdate/choiceUpdate already throttle the heavy work
|
||||
- [x] 3.7 Update `appState`'s onmessage to handle `leafEvaluated`: insert into cache; if cache size > 500_000 (after insert), clear it
|
||||
- [x] 3.8 On `allComplete`, ensure final `topK` / `setAnalyses` come from the worker (which had the full picture) — don't second-guess from cache
|
||||
|
||||
## 4. Cache cap
|
||||
|
||||
- [x] 4.1 Define `const LEAF_CACHE_CAP = 500_000` near the cache ref
|
||||
- [x] 4.2 In the `leafEvaluated` handler, after insertion, check size; if `> LEAF_CACHE_CAP` then `cache.leaves.clear()` (subsequent searches behave as v1.3.1)
|
||||
|
||||
## 5. Tests
|
||||
|
||||
- [x] 5.1 In `app/src/solver/__tests__/searchDecisionTree.test.ts`: add a test that runs `searchDecisionTree` twice on the same scenario, capturing all assignmentKeys from the first run, then passing them as `skipKeys` to the second run. Assert the second run's `iterations` equals `iterationsTotal` (all visited) and that the optimizer was not called for skipped leaves (use a counter via the optimizer mock or by asserting timing — second run should be at least 50× faster)
|
||||
- [x] 5.2 Add a test for `deriveFromLeaves`: run a small `searchDecisionTree`, capture all leaves via a `leafEvaluated`-style hook (or by augmenting the search result), call `deriveFromLeaves` with the same inputs, assert the output `topK` and `setAnalyses` match the search's
|
||||
- [x] 5.3 Add a test for `filterCacheToCurrentState`: build a small synthetic cache, filter for various pinned/excluded states, assert the filtered subset is correct
|
||||
- [x] 5.4 Add a test for cache-cap eviction: synthesize 500_001 leaf insertions; assert cache is cleared after the threshold is crossed
|
||||
- [x] 5.5 Add a test for invalidation: change ranking, then mode; assert cache is empty after each
|
||||
- [x] 5.6 Run full suite; confirm 78+ existing tests still pass
|
||||
|
||||
## 6. Browser verification
|
||||
|
||||
- [x] 6.1 Start dev server. Pin a course and observe: no "searching" spinner appears, top-K and per-set ceilings update instantly
|
||||
- [x] 6.2 Adopt-plan a complete plan (8 pins): instant
|
||||
- [x] 6.3 Unpin a course: cached subset renders immediately; per-set spinner + global progress bar appear; results refine over a few seconds
|
||||
- [x] 6.4 Toggle mode: full re-search runs (cache invalidated)
|
||||
- [x] 6.5 Re-order ranking: full re-search runs (cache invalidated)
|
||||
- [x] 6.6 Verify no console errors; verify memory in devtools stays bounded (<200 MB heap for typical use)
|
||||
|
||||
## 7. Version + changelog
|
||||
|
||||
- [x] 7.1 Bump `__APP_VERSION__` to `1.3.2` and `__APP_VERSION_DATE__` in `app/vite.config.ts`
|
||||
- [x] 7.2 Add `## v1.3.2` entry to `CHANGELOG.md` describing: leaf caching, instant pin/unpin, partial-hit streaming on unpin, 500k cap
|
||||
@@ -0,0 +1,130 @@
|
||||
## Context
|
||||
|
||||
The EMBA Specialization Solver's "Decision Tree" view computes, for each open elective set, the ceiling outcome (best achievable specialization count and which specs) for each course choice. Implementation: `analyzeDecisionTree` (`app/src/solver/decisionTree.ts:90`) runs a per-(set, choice) loop calling `computeCeiling`, which itself enumerates the cartesian product of remaining open sets, runs the optimizer per leaf, and returns the best result by count.
|
||||
|
||||
After adding the Healthcare specialization (J27 update), a contradiction surfaced: HCR shows status "Achievable" but no per-set ceiling cell shows HCR as part of its outcome. Reproduction:
|
||||
|
||||
```
|
||||
Pin: SP2=spr2-health-medical, SP4=spr4-fintech,
|
||||
SP5=spr5-corporate-finance, SE1=sum1-global-immersion
|
||||
Rank: HCR first
|
||||
Result: HCR status = 'achievable' (upper bound = 10 ≥ 9)
|
||||
Decision tree: 0 of 32 ceilings include HCR
|
||||
```
|
||||
|
||||
Diagnostic test confirmed: `priorityOrder` returns `[HCR, BNK]` when fed an HCR-friendly 12-course pin set, so HCR genuinely *is* achievable. The bug is in `computeCeiling`'s comparison (`decisionTree.ts:55`):
|
||||
|
||||
```ts
|
||||
if (result.achieved.length > bestCount) {
|
||||
bestCount = result.achieved.length;
|
||||
bestSpecs = result.achieved;
|
||||
}
|
||||
```
|
||||
|
||||
Strict `>` means the first equal-count result found wins permanently. Combined with declaration-order enumeration, finance-heavy combinations (which appear early in the tree) yield non-HCR `[FIN, MTO]` outcomes that block HCR-including outcomes from ever being recorded.
|
||||
|
||||
The user also wants a richer view than per-set ceilings: a streamed ranked list of complete plans (`PlanOutcome`s, top K=10), each with its full course assignment, achieved specs, and priority score, so they can pick a complete plan rather than reasoning about set choices independently.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Decision-tree outcomes that include the user's top-priority spec surface naturally — both in the per-set table and in a new ranked top-K plan list
|
||||
- One enumeration produces both views (no duplicated work)
|
||||
- Both views update progressively with monotonic improvement (entries only enter or move up)
|
||||
- Search is bounded: terminates on saturation (top-K stable) or hard iteration cap, with a `partial` flag if cap hit
|
||||
- "Achievable" status stays permissive (per user's intent: it indicates reachability anywhere in the tree, regardless of whether a path has been found)
|
||||
|
||||
**Non-Goals:**
|
||||
- Replacing the per-set ceiling table — both views remain
|
||||
- Restructuring the optimizer or LP feasibility checker
|
||||
- Changing optimizer score weights or rank tiebreakers
|
||||
- Designing the visual placement of the new "Top Plans" panel — out of scope here, follow-up brainstorm
|
||||
- User-configurable K — fixed at 10 for this change
|
||||
|
||||
## Decisions
|
||||
|
||||
### Single full-tree DFS instead of nested per-choice loop
|
||||
|
||||
Today's structure: outer loop over (setId, choice), each calling `computeCeiling`, which itself enumerates remaining sets. That's `O(sets × choices × ∏ other-sets-courses)` redundant work — every full path is enumerated up to `setCount` times.
|
||||
|
||||
New structure: one DFS over the cartesian product of all open-set courses. Each leaf evaluates the optimizer once. Per-set ceilings update as side effects ("for each (setId, courseId) in this combination, is this leaf's outcome better than the current ceiling for that cell?"). Top-K updates as side effects too.
|
||||
|
||||
**Alternative considered:** Keep the nested loop and just fix the comparison. Rejected — the algorithm needs to materialize complete plans anyway for the top-K view, and the nested loop's per-choice context isn't useful for that. Switching paradigms is cleaner than bolting top-K onto two enumeration layers.
|
||||
|
||||
### Comparison tuple `(count, priorityScore, deterministic-tiebreak)`
|
||||
|
||||
`priorityScore(specs, ranking)` matches the optimizer's existing definition (`optimizer.ts:71-74`): `sum over specs of (15 - rankIndex(spec))`. Same formula in both modules to avoid drift; extracted into a shared utility.
|
||||
|
||||
Tiebreaker on a deterministic hash of `courseAssignments` ensures streaming order is stable across runs and across worker restarts. Without it, two equally-ranked plans could "swap" position on every emit, causing UI flicker.
|
||||
|
||||
**Alternative considered:** Compare only `(count, priorityScore)` and accept whichever inserted first when equal. Rejected — non-deterministic order makes monotonicity tests unstable and produces visible flicker if two plans tie.
|
||||
|
||||
### `priorityTarget` heuristic = first reachable spec in user's ranking
|
||||
|
||||
Selected once per `analyzeDecisionTree` call. We walk the ranking in order and pick the first specId whose `upperBound >= 9`. If no spec is reachable, `priorityTarget = null` and reordering is skipped (no-op).
|
||||
|
||||
Why "reachable" not just "ranking[0]": if the user's #1 spec has no possible path to 9 credits given the pinned + open universe, prioritizing it would just delay finding good results. Walking to the first reachable one is cheap (one upper-bound array lookup per spec).
|
||||
|
||||
**Alternative considered:** Always use `ranking[0]` regardless of reachability. Rejected — wastes the heuristic on impossible specs in cases where the user has a long ranking and their top picks are gated by missed required courses.
|
||||
|
||||
### Heuristic ordering of DFS children
|
||||
|
||||
Per open set, courses qualifying for `priorityTarget` move to the front (stable sort, ties keep declaration order). Cancelled courses still skipped (existing behavior).
|
||||
|
||||
This causes the FIRST combinations evaluated to include all `priorityTarget`-qualifying choices simultaneously. With the user's ranking (HCR first), the optimizer evaluates an HCR-feasible pin set on iteration 1 and inserts an HCR-achieving outcome immediately into top-K and the relevant per-set ceilings.
|
||||
|
||||
**Alternative considered:** Branch-and-bound style pruning. Rejected — significantly more code, harder to verify correct, and the simple reordering already gives ~order-of-magnitude speedup for the common case.
|
||||
|
||||
### Two complementary terminators: hard cap + saturation
|
||||
|
||||
- `MAX_TREE_ITERATIONS = 10000`: absolute upper bound. Returns `{ partial: true }` if hit.
|
||||
- `SATURATION_LIMIT = 500`: stop if top-K hasn't changed in the last 500 iterations.
|
||||
|
||||
Saturation handles the typical case (top-K converges quickly with the heuristic). Hard cap handles pathological cases (large open-set count, long search space).
|
||||
|
||||
**Alternative considered:** Time-based cap (e.g., 5000ms). Rejected — JS time measurement in a worker is fiddly, and iteration count is a more deterministic test surface. Time cap could be added later if needed.
|
||||
|
||||
**Alternative considered:** Run to exhaustion. Rejected — for ≥8 open sets the cartesian product is in the tens of thousands; full enumeration is O(seconds–minutes) and provides diminishing returns once top-K saturates.
|
||||
|
||||
### `BoundedRankedList<T>` as a sorted array, not a heap
|
||||
|
||||
K ≤ 50 in practice. Insertion sort is `O(K)` per insert. A heap would shave a constant factor but complicates the "did the visible list change?" check (which drives the streaming emits). The simpler structure is fast enough and easier to reason about.
|
||||
|
||||
### Worker emits per-cell `choiceUpdate`, not per-set `setComplete`
|
||||
|
||||
Today, the worker emits one event when an entire set's analysis finishes. Under streaming, a set's ceilings update incrementally as combinations are evaluated. Per-cell events let the UI re-render exactly the changed cell instead of re-rendering the whole set's row.
|
||||
|
||||
**Alternative considered:** Coalesce per-set events on a 100ms timer. Rejected for now — per-cell is simpler and the message volume (a few hundred events per analysis, each <1KB) is well within worker `postMessage` throughput. Coalescing can be added later in the UI layer if needed.
|
||||
|
||||
### "Achievable" status semantics unchanged
|
||||
|
||||
Per user's stated intent: "Achievable" should mean "the spec is reachable somewhere in the remaining decision tree, regardless of priority." The current implementation (`optimizer.ts:185-194`) already does this — it checks the upper bound and returns `achievable` when open sets exist, without verifying joint feasibility with achieved specs.
|
||||
|
||||
This change preserves that semantics. The UX contradiction the user reported ("Achievable but no path shows it") is fixed by making the top-K and per-set views actually find the path, not by tightening the status check.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **Performance regression risk** → Mitigation: heuristic ordering should make typical case faster than today (saturates well before hard cap); performance smoke test verifies user's scenario completes in <5s for K=10 in worker
|
||||
- **Worker message volume** (50–500 small events per analysis) → Mitigation: each event <1KB; UI can coalesce with `requestAnimationFrame` if profiling shows main-thread pressure; defer
|
||||
- **Stable streaming order** depends on deterministic hash of `courseAssignments` → Mitigation: explicit tiebreaker test; document the hash function as part of the public contract
|
||||
- **Two views displaying inconsistent info briefly** during streaming (top-K shows HCR plan, per-set table cell still shows old ceiling for one beat) → Acceptable; both converge on the same data within a few hundred ms
|
||||
- **K=10 fixed** → User-facing limitation; if 10 isn't enough we can ship a follow-up making it configurable. Defer.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
Single-PR change. No data migration. Steps:
|
||||
|
||||
1. Land algorithm + worker + state changes; new "Top Plans" component starts hidden behind a feature flag (or simply absent from the layout) — user-facing UI is added in a sibling commit/PR
|
||||
2. Verify all existing decision-tree tests pass (with priority-tiebreak amendments)
|
||||
3. Verify regression test for user's scenario passes
|
||||
4. Add Top Plans component to layout
|
||||
5. Browser-verify both views update progressively
|
||||
6. Bump version (`1.3.0`), CHANGELOG entry, ship
|
||||
|
||||
Rollback: revert. The change is internal to the decision-tree module and worker protocol; no persistent state to migrate back.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- **UI layout** for the Top Plans panel — handled in a follow-up brainstorm focused on UX
|
||||
- **`MAX_TREE_ITERATIONS = 10000` / `SATURATION_LIMIT = 500`** — initial values; may need tuning after browser-side measurement on representative inputs
|
||||
- **Worker message coalescing** — defer until profiling shows it's needed
|
||||
@@ -0,0 +1,39 @@
|
||||
## Why
|
||||
|
||||
The decision tree currently has a user-visible contradiction: a specialization can be labeled "Achievable" while no per-set ceiling shows a path to achieving it. Concrete reproduction with the new Healthcare specialization (J27): pin SP2=Business of Health & Medical Care, SP4=Foundations of Fintech, SP5=Corporate Finance, SE1=GIE, rank HCR first — HCR shows "Achievable" but every per-set choice's ceiling outcome excludes HCR. Root cause is in `app/src/solver/decisionTree.ts:55`, where `computeCeiling` compares enumerated combinations using strict `>` on count alone, so the first equal-count outcome found wins permanently regardless of the user's priority ranking.
|
||||
|
||||
Beyond the bug, the tool currently shows only per-(set, choice) ceiling cells. Users have no global "best plans" view and must mentally compose compatible choices across sets. The fix and the new view share the same enumeration work, so addressing both together is cheaper than addressing them sequentially.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Replace `analyzeDecisionTree`'s nested `computeCeiling` loop with a single full-tree search (`searchDecisionTree`) that simultaneously populates two outputs from one DFS:
|
||||
- The existing per-set per-choice ceiling table (unchanged shape, now updated progressively per cell)
|
||||
- A new bounded ranked list of up to K complete plan outcomes (`PlanOutcome[]`, default K=10)
|
||||
- Comparison rule everywhere becomes `(count desc, priorityScore desc, deterministic tiebreaker)`. Extract `priorityScore` from `optimizer.ts:71-74` into a shared utility used by both modules.
|
||||
- Reorder DFS children at every level so courses qualifying for the user's first reachable top-ranked spec (the `priorityTarget`) are tried first. This ensures high-priority outcomes surface early in the stream.
|
||||
- Bound the search with two complementary terminators: a hard iteration cap (`MAX_TREE_ITERATIONS = 10000`) and saturation termination when the top-K has not changed for the last `SATURATION_LIMIT = 500` iterations. Return a `partial: true` flag if the cap is hit before saturation.
|
||||
- Worker protocol: `WorkerRequest` gains optional `topK` (default 10) and `saturationLimit`. `WorkerResponse` becomes a tagged union with three event types: `topKUpdate`, `choiceUpdate` (replaces today's coarser `setComplete`), and `allComplete` (now carries `topK` and `partial`).
|
||||
- App state and a new "Top Plans" UI panel consume the streamed top-K; the existing per-set table consumes the finer-grained `choiceUpdate` events. Per-set table component shape is unchanged.
|
||||
- "Achievable" status semantics stay permissive (raw upper-bound check). Per the user's intent, this is correct: it should mean "reachable somewhere in the tree" regardless of whether the search has yet found a path.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
_None — this extends an existing capability rather than adding a new one._
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `optimization-engine`: introduces the streaming top-K search, priority-aware ceiling comparison, heuristic enumeration ordering, and the new worker event protocol. The optimizer itself (LP solver, S2 enumeration) is untouched; this change touches the decision-tree layer that wraps it.
|
||||
|
||||
## Impact
|
||||
|
||||
- `app/src/solver/decisionTree.ts` — major rewrite: new `searchDecisionTree`, `BoundedRankedList`, `PlanOutcome` type, `priorityTarget` selection, child reordering, saturation termination
|
||||
- `app/src/solver/optimizer.ts` — extract `priorityScore` (currently inline at lines 71-74) into a shared utility (either exported from optimizer or a new `priority.ts`)
|
||||
- `app/src/workers/decisionTree.worker.ts` — message-protocol update; consume `topK`/`saturationLimit` request fields, emit `topKUpdate`/`choiceUpdate`/`allComplete` tagged events
|
||||
- `app/src/state/appState.ts` — add `topK` slice, wire new event types from worker
|
||||
- `app/src/components/` — new `TopPlans.tsx` (or similar) component; existing decision-tree per-set component switches from `setComplete` to per-cell `choiceUpdate` handler
|
||||
- `app/src/solver/__tests__/decisionTree.test.ts` — add scenario regression test, priority-ordering test, monotonicity test, saturation/cap tests; existing 4 tests must continue to pass (with priority-tiebreak amendments where needed)
|
||||
- `app/vite.config.ts` — version bump (`__APP_VERSION__` to `1.3.0`, date today)
|
||||
- `CHANGELOG.md` — release entry
|
||||
- No data-file changes; no schema migration; no backwards-compatibility shims (the worker is internal)
|
||||
@@ -0,0 +1,73 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Streamed ranked top-K plan outcomes
|
||||
The decision-tree analysis SHALL maintain a bounded ranked list of up to K complete plan outcomes (default K=10) and emit a stream update each time the visible list changes. Each `PlanOutcome` SHALL include the full course assignment for open sets (`Record<setId, courseId>`), the achieved specializations, and the priority score used for ranking. The list SHALL be ordered by `(achievedSpecs.length descending, priorityScore descending, deterministic tiebreaker on courseAssignments)`.
|
||||
|
||||
#### Scenario: Top-K converges on user's top-priority spec
|
||||
- **WHEN** the user pins courses such that their first reachable ranked spec (e.g., HCR) is achievable somewhere in the remaining decision tree
|
||||
- **THEN** the final `topK[0].achievedSpecs` includes that spec
|
||||
|
||||
#### Scenario: Streaming is monotonically improving
|
||||
- **WHEN** the worker emits a sequence of `topKUpdate` events during a single analysis
|
||||
- **THEN** each emitted topK is greater than or equal to the previous one entry-for-entry under the comparator
|
||||
|
||||
#### Scenario: Tied outcomes with different course plans appear as separate entries
|
||||
- **WHEN** two distinct course assignments produce the same `achievedSpecs` and `priorityScore`
|
||||
- **THEN** both appear as separate ranked entries in the top-K (deterministic tiebreaker resolves their order)
|
||||
|
||||
### Requirement: Heuristic enumeration ordering
|
||||
The decision-tree search SHALL identify a `priorityTarget` (the first specialization in the user's ranking whose upper-bound credit potential meets the threshold) and SHALL reorder the children at each level of its DFS so that courses qualifying for the `priorityTarget` are tried before courses that do not. Reordering SHALL be a stable sort that preserves declaration order on ties.
|
||||
|
||||
#### Scenario: Priority target derived from first reachable ranked spec
|
||||
- **WHEN** the user's ranking is `[HCR, BNK, ...]` and HCR's upper bound is ≥ 9
|
||||
- **THEN** `priorityTarget = 'HCR'` and DFS children at every level are reordered HCR-first
|
||||
|
||||
#### Scenario: No reachable spec disables the heuristic
|
||||
- **WHEN** no specialization in the ranking has upper bound ≥ 9
|
||||
- **THEN** `priorityTarget = null` and DFS children are not reordered
|
||||
|
||||
### Requirement: Bounded search with saturation termination
|
||||
The decision-tree search SHALL terminate when EITHER (a) the top-K ranked list has not changed for the last `SATURATION_LIMIT` iterations (default 500), OR (b) the iteration count exceeds `MAX_TREE_ITERATIONS` (default 10000). When (b) terminates the search before (a), the result SHALL include `partial: true`.
|
||||
|
||||
#### Scenario: Saturation stops a converged search early
|
||||
- **WHEN** the top-K becomes stable well before the iteration cap
|
||||
- **THEN** the search stops within `SATURATION_LIMIT` iterations of the last top-K change
|
||||
|
||||
#### Scenario: Iteration cap stops an unconverged search
|
||||
- **WHEN** the search would otherwise enumerate beyond `MAX_TREE_ITERATIONS` combinations
|
||||
- **THEN** the search returns its best-found top-K with `partial: true`
|
||||
|
||||
### Requirement: Per-cell choice updates from streaming search
|
||||
For each combination evaluated in the search, for each `(setId, courseId)` in that combination's assignments, the per-set per-choice ceiling SHALL be updated if the combination's outcome is better under the comparison rule than the current ceiling for that choice. Each ceiling change SHALL emit a `choiceUpdate` event identifying the affected `setId` and the updated `SetAnalysis`.
|
||||
|
||||
#### Scenario: Per-set ceiling reflects streamed improvements
|
||||
- **WHEN** an HCR-feasible combination is evaluated mid-search
|
||||
- **THEN** the per-set ceiling cell for `spr3-analytics-ml` (the HCR-qualifying course in spr3) is updated to include HCR
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Decision-tree per-set ceiling comparison
|
||||
For each open elective set and each course choice within that set, the system SHALL compute a ceiling outcome representing the best achievable specialization result if that course is pinned. The "best" outcome SHALL be determined by `(achievedSpecs.length descending, priorityScore descending, deterministic tiebreaker)`, where `priorityScore` matches the optimizer's existing definition (`sum over specs of (15 - rankIndex(spec))`). When two outcomes have the same count, the higher priority score wins.
|
||||
|
||||
#### Scenario: Equal-count outcomes resolved by priority score
|
||||
- **WHEN** the search finds two combinations both achieving 2 specializations, one with `[FIN, MTO]` and another with `[HCR, BNK]`, and the user's ranking places HCR first
|
||||
- **THEN** the per-set ceiling reflects `[HCR, BNK]` (higher priority score)
|
||||
|
||||
#### Scenario: Higher count beats higher priority
|
||||
- **WHEN** one combination achieves 3 specializations not including the top-priority spec, and another achieves 2 specializations including it
|
||||
- **THEN** the 3-specialization outcome wins
|
||||
|
||||
### Requirement: Decision-tree worker protocol
|
||||
The decision-tree worker SHALL accept a `WorkerRequest` that includes optional `topK` (default 10) and `saturationLimit` (default 500) parameters. It SHALL emit a tagged-union `WorkerResponse` stream with three event types: `topKUpdate` (when the ranked top-K list changes), `choiceUpdate` (when a per-set ceiling cell changes), and `allComplete` (when the search terminates, carrying both final top-K and final per-set analyses, plus a `partial` flag).
|
||||
|
||||
#### Scenario: Worker accepts K parameter
|
||||
- **WHEN** the request specifies `topK: 5`
|
||||
- **THEN** the worker maintains a bounded list of at most 5 entries and emits updates accordingly
|
||||
|
||||
#### Scenario: Worker emits final allComplete event
|
||||
- **WHEN** the search terminates (saturation or cap)
|
||||
- **THEN** the worker emits `{ type: 'allComplete', topK, setAnalyses, partial }`
|
||||
|
||||
#### Scenario: Worker emits per-cell choice updates rather than per-set rollups
|
||||
- **WHEN** a single combination causes a ceiling change for one course in one set
|
||||
- **THEN** the worker emits one `choiceUpdate` event identifying that set, not a coarse `setComplete` rollup
|
||||
@@ -0,0 +1,73 @@
|
||||
## 1. Shared Priority Utility
|
||||
|
||||
- [x] 1.1 Extract `priorityScore(specs: string[], ranking: string[]): number` from `app/src/solver/optimizer.ts:71-74` into a new exported helper (in `optimizer.ts` or a new `app/src/solver/priority.ts` — pick whichever fits the existing import patterns better)
|
||||
- [x] 1.2 Update `maximizeCount` to call the shared helper instead of the inline definition
|
||||
- [x] 1.3 Add a unit test covering `priorityScore` with a few rankings to lock the formula
|
||||
|
||||
## 2. New Types and Data Structures
|
||||
|
||||
- [x] 2.1 In `app/src/solver/decisionTree.ts` (or a sibling file), define `interface PlanOutcome { courseAssignments: Record<string, string>; achievedSpecs: string[]; priorityScore: number }`
|
||||
- [x] 2.2 Implement `BoundedRankedList<T>`: bounded sorted-array container with `tryInsert(item): boolean` and `toArray(): T[]`. Tests: insert above capacity drops worst entry; insert returns true only when list visibly changes
|
||||
- [x] 2.3 Define a deterministic stringification of `courseAssignments` (sorted by setId, joined) for use as the comparator's tiebreaker
|
||||
|
||||
## 3. Search Algorithm
|
||||
|
||||
- [x] 3.1 Implement `selectPriorityTarget(ranking, upperBounds): string | null` — walk ranking, return first specId with `upperBounds[id] >= 9`, else null
|
||||
- [x] 3.2 Implement `reorderForTarget(setId, target, excludedCourseIds): Course[]` — stable sort `coursesBySet[setId]` so `target`-qualifying courses come first; cancelled courses still excluded
|
||||
- [x] 3.3 Implement `searchDecisionTree(pinned, openSets, ranking, mode, K, callbacks, excluded)`:
|
||||
- DFS over cartesian product, children reordered per `priorityTarget`
|
||||
- Per leaf: run optimizer, build `PlanOutcome`, `topK.tryInsert`, update per-set ceilings
|
||||
- Emit `topKUpdate` and `choiceUpdate` callbacks on changes
|
||||
- Track iteration count; track iterations-since-last-topK-change for saturation
|
||||
- Terminate at `MAX_TREE_ITERATIONS = 10000` (set `partial=true`) or `SATURATION_LIMIT = 500` iterations of no topK change
|
||||
- Return `{ topK, setAnalyses, partial }`
|
||||
- [x] 3.4 Replace `analyzeDecisionTree`'s body with a thin wrapper that calls `searchDecisionTree`. Preserve its existing exported signature so existing call sites continue to compile; add an overload (or new exported function) that exposes the streaming/topK API for consumers that need it
|
||||
- [x] 3.5 Delete the old `computeCeiling` function once `searchDecisionTree` is wired in (no longer called)
|
||||
|
||||
## 4. Comparison Rule
|
||||
|
||||
- [x] 4.1 Implement `compareOutcomes(a, b)` returning `<0 / 0 / >0` for `(count desc, priorityScore desc, deterministic-tiebreak asc)`
|
||||
- [x] 4.2 Use `compareOutcomes` for both `BoundedRankedList`'s comparator and `setAnalyses[setId].choices[courseId]` ceiling updates
|
||||
|
||||
## 5. Worker Protocol
|
||||
|
||||
- [x] 5.1 Update `app/src/workers/decisionTree.worker.ts`:
|
||||
- Extend `WorkerRequest` with optional `topK` (default 10) and `saturationLimit` (default 500)
|
||||
- Replace single `setComplete` event with three event types: `topKUpdate`, `choiceUpdate`, `allComplete`
|
||||
- On worker invocation, pass callbacks into `searchDecisionTree` that `postMessage` for each event
|
||||
- [x] 5.2 Update `WorkerResponse` type to the tagged union from the spec; ensure all consumers in app code switch on `type`
|
||||
|
||||
## 6. App State Wiring
|
||||
|
||||
- [x] 6.1 In `app/src/state/appState.ts`, add a `topK: PlanOutcome[]` slice (and `topKPartial: boolean` flag) and a handler that updates from `topKUpdate` events
|
||||
- [x] 6.2 Update the existing handler that consumes worker events to handle the new `choiceUpdate` shape (per-cell rather than per-set rollup) and the new `allComplete` shape (carries final topK + setAnalyses + partial)
|
||||
- [x] 6.3 Send the new `topK` and `saturationLimit` parameters in the worker request (use defaults 10 and 500)
|
||||
|
||||
## 7. UI: Top Plans Panel (sketch only — full UX in follow-up)
|
||||
|
||||
- [x] 7.1 Create `app/src/components/TopPlans.tsx` rendering the `topK` slice as a ranked list. Each row: achieved specs as badges, list of "set → course" pairs from `courseAssignments`, an "Adopt plan" button that pins all those courses
|
||||
- [x] 7.2 Add the panel to the layout in a sensible default location (below or beside the existing decision-tree section). Mark it as a "preview" / minor visual treatment if not yet polished — full UX work tracked separately
|
||||
- [x] 7.3 If `topKPartial` is true, render a subtle "(showing best of N explored)" caption
|
||||
|
||||
## 8. Tests
|
||||
|
||||
- [x] 8.1 Reproduction test in `app/src/solver/__tests__/decisionTree.test.ts`: pin SP2=spr2-health-medical, SP4=spr4-fintech, SP5=spr5-corporate-finance, SE1=sum1-global-immersion; rank `[HCR, ...]`. Assert `topK[0].achievedSpecs.includes('HCR')` AND `setAnalyses` for spr3 has analytics-ml's `ceilingSpecs.includes('HCR')`
|
||||
- [x] 8.2 Priority-aware ordering test: same count, two combinations — assert the higher-priority combination wins both topK position and per-set ceiling
|
||||
- [x] 8.3 Streaming monotonicity test: capture all emitted topK snapshots; assert each is ≥ the previous under `compareOutcomes` for matching positions
|
||||
- [x] 8.4 Saturation early-termination test: input where topK converges quickly; assert iteration count stays well under `MAX_TREE_ITERATIONS`
|
||||
- [x] 8.5 Iteration cap test: input that would never saturate (or set `SATURATION_LIMIT = Infinity` in test); assert `partial: true` after `MAX_TREE_ITERATIONS`
|
||||
- [x] 8.6 Existing decision-tree tests (4 tests in `decisionTree.test.ts`): must continue to pass. Update assertions only where priority-tiebreak changes the result (document each amendment in the commit message)
|
||||
- [x] 8.7 Performance smoke test: user's 8-open-set scenario, K=10, completes in < 5s on Node test runner
|
||||
|
||||
## 9. Browser Verification
|
||||
|
||||
- [x] 9.1 Start dev server and load app
|
||||
- [x] 9.2 Reproduce the user's scenario (pin 4 courses, rank HCR first); confirm Top Plans panel shows at least one plan with HCR achieved
|
||||
- [x] 9.3 Confirm per-set ceiling table cells update progressively (visible streaming behavior) — at least the spr3 analytics-ml cell flips to include HCR
|
||||
- [x] 9.4 Verify "Adopt plan" button correctly pins the plan's courses and updates the rest of the UI
|
||||
- [x] 9.5 Test with a scenario where no spec is reachable — confirm priorityTarget=null path runs without error and returns sensible (possibly empty) topK
|
||||
|
||||
## 10. Version + Changelog
|
||||
|
||||
- [x] 10.1 Bump `__APP_VERSION__` to `1.3.0` and `__APP_VERSION_DATE__` in `app/vite.config.ts`
|
||||
- [x] 10.2 Add `## v1.3.0` entry to `CHANGELOG.md` describing: priority-aware ceiling fix, new streamed top-K plan list, worker protocol change, fixes the HCR-achievable-but-no-path bug
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-05-09
|
||||
@@ -0,0 +1,129 @@
|
||||
## Context
|
||||
|
||||
The desktop layout in `App.tsx` today is a 2-column grid: a 340px specialization rail on the left and a 1fr right pane on the right that stacks `TopPlans` over `CourseSelection` and scrolls together. The right pane must accommodate three concerns at once — search progress, plan list, and a 12-set schedule — and ranking the specs ties up significant width that the schedule could use.
|
||||
|
||||
Within `CourseSelection`, each elective set renders its courses as full-width vertically stacked rows. On desktop the right pane is ~836px wide, so each row uses lots of horizontal space for one course; the schedule extends well past the viewport.
|
||||
|
||||
The search progress bar lives inside `TopPlans`, so when a user is scrolled down looking at the schedule it disappears.
|
||||
|
||||
This change reflows the desktop layout to:
|
||||
1. A horizontal spec strip at the top (drag L→R to rank).
|
||||
2. A hoisted, full-width progress bar.
|
||||
3. A 2-column workspace below: Top Plans (left) | Schedule (right), each with independent scroll.
|
||||
4. Schedule blocks render their course choices as a horizontal flex row of buttons rather than stacked rows.
|
||||
|
||||
Mobile (≤768px in the project's `useMediaQuery` hook) keeps its current vertical arrangement — this is a desktop-only redesign.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Eliminate the "Top Plans pushes Schedule below the fold" problem by giving each its own column with independent scroll.
|
||||
- Free vertical space in the schedule by laying course choices out horizontally per elective set.
|
||||
- Keep search progress visible from any scroll position by hoisting the progress bar.
|
||||
- Preserve all existing semantics: pinning, recommended star, ceiling tags, info popover, cancelled/already-selected states, per-course searching, mode comparison banner, achievement count.
|
||||
- Preserve mobile UX exactly — no regression in the mobile experience.
|
||||
|
||||
**Non-Goals:**
|
||||
- No solver, worker, or data-layer changes. The decision-tree pipeline and leaf cache are untouched.
|
||||
- No new features (e.g., new filters, new modes, search of plans). Pure UI reflow.
|
||||
- No tablet-specific rework. Tablet is treated the same as desktop in `useMediaQuery` (returns `desktop` for ≥768px), so it inherits the new layout. No separate intermediate layout.
|
||||
- The horizontal chip strip is desktop-only; mobile is not getting horizontal chips.
|
||||
|
||||
## Decisions
|
||||
|
||||
### D1: Specs go to a horizontal strip with hover popover detail
|
||||
|
||||
Compact 15-chip strip in a single row, each ~70px. Each chip shows rank number, 3-letter abbreviation (already in `SPECIALIZATIONS`), and a micro credit bar. Background color encodes status using the existing `STATUS_STYLES` palette.
|
||||
|
||||
Detail (full name, status word, allocated/threshold numeric, contributing-courses breakdown) lives in a hover popover anchored to the chip. Touch users on hybrid devices get the same popover via tap-toggle (`onClick`).
|
||||
|
||||
**Why not vertical-with-collapsed-detail?** Considered, but the user's mental model is "left = highest priority" — horizontal flow matches reading direction and frees the entire left rail for the workspace below. A wrap-to-2-rows variant was rejected because it breaks the left-to-right priority continuity.
|
||||
|
||||
**Why not include the full row content (name, numbers, status word) in each chip?** 70px is too narrow. Hover popover gets the detail without crowding the strip.
|
||||
|
||||
### D2: Drag strategy switches to horizontal
|
||||
|
||||
`@dnd-kit/sortable` is already a dependency. Switch the `SortableContext` strategy from `verticalListSortingStrategy` to `horizontalListSortingStrategy` on desktop. Mobile keeps `verticalListSortingStrategy`. We branch on `isMobile` from the existing `useMediaQuery` hook inside `SpecializationRanking`.
|
||||
|
||||
### D3: Progress bar hoisted into a global strip
|
||||
|
||||
Today `TopPlans` renders both the progress bar and the "search complete / cap hit" status text. Extract a small `SearchProgressStrip` component (or inline JSX in `App.tsx`) that consumes the same `searchProgress` and `treeLoading` state from `useAppState`. `TopPlans` keeps the static "search complete" text in its header (it pertains to the plan list specifically), but the animated progress bar moves out.
|
||||
|
||||
On mobile the progress bar stays inside `TopPlans` to avoid stealing precious vertical space at the top.
|
||||
|
||||
### D4: 2-column workspace with independent scroll
|
||||
|
||||
```
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 24px;
|
||||
flex: 1;
|
||||
min-height: 0; /* required so children can shrink and scroll */
|
||||
|
||||
each child: overflowY: auto; min-height: 0;
|
||||
```
|
||||
|
||||
This is a small reuse of the existing pattern in `App.tsx` (the current right pane already uses `overflowY: auto; min-height: 0`).
|
||||
|
||||
**Why 1fr / 1fr (50/50)?** Plans rows render 12 mini-set buttons that benefit from width; schedule blocks render 3-5 buttons per row that also benefit from width. Neither dominates. Asymmetric splits (e.g., 40/60) showed plan mini-cards wrapping awkwardly in the 40 case.
|
||||
|
||||
### D5: Schedule course buttons go horizontal (flex stretch)
|
||||
|
||||
Each non-pinned elective set replaces its column-of-rows with:
|
||||
|
||||
```jsx
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{courses.map(c => <CourseButton style={{ flex: '1 1 0' }} ... />)}
|
||||
</div>
|
||||
```
|
||||
|
||||
Equal width per button within a set; widths vary set-to-set (3-button sets have wider buttons than 5-button sets). No flex-wrap — tested width math (109px minimum for 5-button case in a 588px column) is acceptable for the 2-line line-clamp.
|
||||
|
||||
**Per-button anatomy:**
|
||||
- Top row: info icon (existing `i` button, top-left), recommended star (top-right when applicable).
|
||||
- Middle: course name with `WebkitLineClamp: 2-3`. Title attribute carries full name for hover.
|
||||
- Footer: spec-tag row (existing `SpecTag` component), allowed to wrap if a course has 4+ specs.
|
||||
- Cancelled/already-selected: gray background, strikethrough name, small footer text "Cancelled" or "Already selected".
|
||||
- Searching: pulsing skeleton bar where spec tags would render.
|
||||
- Required-for note: small amber footer line below the spec tags.
|
||||
|
||||
**Pinned sets stay as today:** single line "Course Name + Clear button". Only unpinned sets get the new horizontal layout.
|
||||
|
||||
**Why not a CSS grid with auto-fit?** Considered `grid-template-columns: repeat(auto-fit, minmax(110px, 1fr))` which would wrap automatically. Rejected because it can produce inconsistent per-set widths that don't align with the set's actual course count, and because the courses-per-set is bounded (3–5) so flex with no wrap is simpler.
|
||||
|
||||
### D6: Term headings (Spring/Summer/Fall) remain as section dividers
|
||||
|
||||
User confirmed. They're cheap (~30px each) and orient the user temporally. No change needed.
|
||||
|
||||
### D7: CreditLegend collapses into the spec strip header
|
||||
|
||||
Today `CreditLegend` is its own block in the right pane. It would be in the way of the new column split. Rendered as a small `[▸ legend]` toggle button in the spec strip's right-aligned header area; clicking expands an inline panel below the strip with the same content the legend already provides. The component itself stays largely as-is — it's already a self-contained collapsible.
|
||||
|
||||
### D8: Hover popover positioning for spec chips
|
||||
|
||||
Anchor to the chip's bounding rect. Reuse the smart-flip logic already in `CourseInfoPopover` (`spaceBelow` / `spaceAbove`, `placeAbove`) for vertical fit. Horizontally, clamp `left` to `Math.min(rect.left, window.innerWidth - popoverWidth - 8)` so chips near the right edge don't clip the popover off-screen.
|
||||
|
||||
Use a 150ms close delay (same pattern as the course info popover) so the user can move the cursor from chip → popover without it disappearing.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **Risk: 70px chip width doesn't render legibly at narrower desktop viewports (e.g., 1024px usable).** → Mitigation: the layout's `maxWidth: 1200px` is centered, so a 1024px viewport gives a smaller container. We allow horizontal scrolling on the chip strip if the strip content exceeds container width (`overflowX: auto`) as a safety valve. Cleaner option: enforce a min container width (e.g., 1200px → fall back to mobile-style vertical layout below it). Decision: take the `overflowX: auto` safety valve; the project uses a desktop breakpoint of `≥768px` and below ~1100px the chip strip will get a horizontal scrollbar on the strip element only.
|
||||
- **Risk: Course buttons with 4+ spec tags overflow at narrow column widths.** → Mitigation: spec tag row uses `flex-wrap: wrap`; button heights flex-stretch so the row of buttons stays vertically aligned even when one button wraps to a 3-line spec-tag area.
|
||||
- **Risk: Touch device on the desktop breakpoint (e.g., iPad in landscape, when `useMediaQuery` may treat it as desktop) has no real hover.** → Mitigation: tap on a chip toggles the popover open/closed; tap outside closes. Same pattern as `CourseInfoPopover`. Use `onClick` for parity with hover on desktop.
|
||||
- **Risk: Drag-and-drop horizontal feels different from vertical and may need tweaked activation distance.** → Mitigation: keep the existing `PointerSensor` activation distance (5px). `horizontalListSortingStrategy` works out of the box for left-right reorders. Test with keyboard-driven reorder (arrow keys via `KeyboardSensor`).
|
||||
- **Risk: Hoisted progress bar feels disconnected from "Top Plans" since the static "Search complete · X explored" text remains in the plan list header.** → Mitigation: keep the static text in `TopPlans` (it's a state summary), move only the animated bar. The user mental model is "progress = global; result counts per panel = local." Acceptable.
|
||||
- **Risk: Mobile regression.** → Mitigation: `App.tsx` already branches on `isMobile`; we keep the mobile branch's JSX identical and only restructure the desktop branch. Spec strip, course button row, and column split are all gated behind `!isMobile`. The existing `MobileStatusBanner` and `MobileCourseBanner` continue to anchor to `specSectionRef` and `courseSectionRef` — those refs need to point to mobile-only sections, which we keep.
|
||||
- **Trade-off: Lost at-a-glance numeric credit display in the spec strip.** Users wanting "5.5 / 9.0" must hover. Acceptable given the strip's information density goal.
|
||||
- **Trade-off: Lost at-a-glance status text label ("Achieved", "Achievable", etc.) in the spec strip.** Background color carries this. Status word still visible in the hover popover. Color-only meets design goal but accessibility could improve later with optional text dot/glyph; not in scope here.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
This is a UI-only change. No data migration, no API contract change, no breaking persistence changes (`localStorage` keys for ranking and pinned courses untouched).
|
||||
|
||||
Rollout: ship in v1.4.0 (minor bump per CHANGELOG convention). Single deployment; no flag gating needed since the behavior is purely visual.
|
||||
|
||||
If the redesign needs to be reverted, revert the App.tsx, SpecializationRanking.tsx, TopPlans.tsx, CourseSelection.tsx changes and restore the version bump. State and solver code are untouched.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- None blocking implementation. Width edge cases (very narrow desktop viewports, very wide popover content) are handled by `overflowX: auto` + popover smart-flip.
|
||||
@@ -0,0 +1,39 @@
|
||||
## Why
|
||||
|
||||
On desktop the right pane stacks Top Plans on top of the Schedule, so when the search produces several plans they push the 12 elective-set blocks below the fold. Users lose their spatial sense of the schedule while exploring plans, and the schedule blocks themselves stack each course as a full-width row, which is wasteful of horizontal space and makes the page feel taller than it needs to be.
|
||||
|
||||
The specialization ranking sits in a 340px left rail and forces the rest of the page into a narrower right pane. Moving it to a horizontal strip at the top frees the full container width for the workspace below and matches the natural "reading order" of priority (left = highest).
|
||||
|
||||
## What Changes
|
||||
|
||||
- Desktop layout reflows from 2-column (specs | right pane) to a top spec strip + global progress bar + 2-column workspace (Top Plans | Schedule), each column scrolling independently.
|
||||
- Specialization ranking becomes a single horizontal row of 15 compact chips (~70px each). Each chip shows rank, abbreviation, and a micro credit bar; full name, status word, allocated/threshold, and contributing-course breakdown move into a hover popover. Drag-to-reorder switches from `verticalListSortingStrategy` to `horizontalListSortingStrategy`.
|
||||
- The search progress bar is hoisted out of `TopPlans` into a thin global strip below the spec band so it stays visible from both columns.
|
||||
- Each elective-set block in the Schedule column renders its courses as a horizontal flex row of equal-width buttons (3–5 per set, depending on data) instead of stacked rows. Each button shows: info icon (top-left), recommended star (top-right when applicable), course name with line-clamp, and a bottom row of spec ceiling tags. Cancelled / already-selected / per-course-searching states keep their semantics, restyled into the button form.
|
||||
- SPRING / SUMMER / FALL term headings remain as section dividers in the Schedule column.
|
||||
- `CreditLegend` collapses into a `[▸ legend]` toggle in the spec strip header (it currently sits in the right pane, where it would be in the way under the new layout).
|
||||
- Mobile (≤768px) layout is unchanged — vertical specialization list, stacked Top Plans + Schedule. The redesign is desktop-only.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
_None._
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `responsive-layout`: redefines the desktop arrangement as a top spec strip + hoisted progress bar + 2-column workspace (Top Plans | Schedule) with independent scrolling. Mobile and tablet behavior unchanged.
|
||||
- `unified-specialization-panel`: adds a desktop-only horizontal-strip rendering with compact chips, hover popover for detail, and horizontal drag-to-reorder. Mobile keeps the existing vertical list.
|
||||
- `unified-course-panel`: redesigns each non-pinned elective-set block on desktop to render courses as a horizontal flex row of buttons. Pinned-set rendering and mobile rendering unchanged.
|
||||
|
||||
## Impact
|
||||
|
||||
- `app/src/App.tsx` — restructure desktop layout: top spec strip, progress strip, 2-column grid for plans/schedule, each scrolling independently. Mobile branch untouched.
|
||||
- `app/src/components/SpecializationRanking.tsx` — add desktop-strip variant: compact chip rendering, hover-popover detail with full name + status + numeric credits + allocation breakdown, switch dnd-kit strategy to `horizontalListSortingStrategy` on desktop. Keep vertical rendering on mobile.
|
||||
- `app/src/components/TopPlans.tsx` — remove the inline progress bar and the "search complete / cap hit" status text; render only the plan list. The progress bar moves to a new component.
|
||||
- New `app/src/components/SearchProgressStrip.tsx` (or inline in `App.tsx`) — renders the hoisted progress bar with the same `progress` and `loading` inputs `TopPlans` used today.
|
||||
- `app/src/components/CourseSelection.tsx` — restyle the unpinned-set rendering: replace the column-of-rows with a flex row of buttons. Buttons absorb info-icon + recommended badge + course name + spec tag row + cancelled/selected/searching states. Pinned-set view unchanged.
|
||||
- `app/src/components/CreditLegend.tsx` — consumed via the spec strip header toggle; component itself can stay as-is or be inlined.
|
||||
- No solver, worker, or data-file changes.
|
||||
- `app/vite.config.ts` — bump to `1.4.0`.
|
||||
- `CHANGELOG.md` — release entry.
|
||||
@@ -0,0 +1,44 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Mobile-first responsive layout
|
||||
The app SHALL adapt its layout to two viewport classes: mobile (<768px) and desktop (≥768px). On mobile, all panels SHALL stack vertically in a single column. On desktop, the layout SHALL use a top-to-bottom arrangement of: header + mode toggle + mode comparison banner, a horizontal specialization strip, a global search-progress strip, and a 2-column workspace below containing Top Plans (left) and Schedule (right). The two workspace columns SHALL scroll independently while the spec strip and progress strip SHALL remain fixed at the top of the workspace area.
|
||||
|
||||
#### Scenario: Mobile viewport
|
||||
- **WHEN** the viewport width is less than 768px
|
||||
- **THEN** the layout SHALL display as a single column with the specialization panel above the Top Plans section above the Schedule section, all full-width
|
||||
|
||||
#### Scenario: Desktop viewport
|
||||
- **WHEN** the viewport width is 768px or greater
|
||||
- **THEN** the layout SHALL display the horizontal specialization strip across the top, a global progress strip directly below it, and a 2-column workspace below the progress strip with Top Plans on the left and Schedule on the right
|
||||
|
||||
#### Scenario: Independent column scroll on desktop
|
||||
- **WHEN** the user is on desktop and scrolls inside the Top Plans column
|
||||
- **THEN** only the Top Plans column SHALL scroll while the Schedule column, the progress strip, and the specialization strip SHALL remain in place
|
||||
|
||||
#### Scenario: Independent column scroll on desktop (Schedule)
|
||||
- **WHEN** the user is on desktop and scrolls inside the Schedule column
|
||||
- **THEN** only the Schedule column SHALL scroll while the Top Plans column, the progress strip, and the specialization strip SHALL remain in place
|
||||
|
||||
### Requirement: Notification banners span full width
|
||||
Mode comparison and mutual exclusion warnings SHALL render as full-width banners above the main panel layout, not inside a specific column.
|
||||
|
||||
#### Scenario: Warning banner placement
|
||||
- **WHEN** a mutual exclusion warning or mode comparison banner is active
|
||||
- **THEN** the banner SHALL appear between the header/mode toggle and the specialization strip (desktop) or the specialization panel (mobile), spanning the full container width
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Global search-progress strip on desktop
|
||||
On desktop, the active search progress bar SHALL render as a thin full-width strip directly below the specialization strip and above the workspace columns. It SHALL remain visible from any scroll position within either workspace column. On mobile, the progress bar SHALL remain inside the Top Plans section as today.
|
||||
|
||||
#### Scenario: Search in progress on desktop
|
||||
- **WHEN** the decision-tree search is running on desktop
|
||||
- **THEN** an animated progress bar SHALL render in a strip between the specialization strip and the workspace columns, showing the iteration count and percent complete
|
||||
|
||||
#### Scenario: Search complete on desktop
|
||||
- **WHEN** the decision-tree search has completed on desktop
|
||||
- **THEN** the progress bar SHALL no longer animate; the static "Search complete · N explored" or "Search incomplete · cap hit at N" status MAY remain visible inline with the Top Plans header
|
||||
|
||||
#### Scenario: Search progress on mobile
|
||||
- **WHEN** the decision-tree search is running on mobile
|
||||
- **THEN** the progress bar SHALL render inside the Top Plans section, not in a global strip
|
||||
@@ -0,0 +1,105 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Desktop horizontal course button arrangement
|
||||
On desktop, each non-pinned elective set in the Schedule column SHALL render its course choices as a horizontal flex row of equal-width buttons (one button per course), with each button stretching to fill its share of the row. Pinned sets SHALL continue to render as today (single-line course name with a Clear button). On mobile, course choices SHALL continue to render as vertically stacked rows.
|
||||
|
||||
#### Scenario: Desktop unpinned set renders horizontal row
|
||||
- **WHEN** the user views an unpinned elective set on desktop
|
||||
- **THEN** the set's course choices SHALL render as a horizontal flex row of equal-width buttons, one button per course
|
||||
|
||||
#### Scenario: Desktop unpinned set with three courses
|
||||
- **WHEN** an unpinned elective set has 3 courses on desktop
|
||||
- **THEN** the row SHALL render 3 buttons stretched to fill the available width
|
||||
|
||||
#### Scenario: Desktop unpinned set with five courses
|
||||
- **WHEN** an unpinned elective set has 5 courses on desktop
|
||||
- **THEN** the row SHALL render 5 buttons stretched to fill the available width, narrower than the 3-course case
|
||||
|
||||
#### Scenario: Pinned set on desktop unchanged
|
||||
- **WHEN** an elective set is pinned on desktop
|
||||
- **THEN** the set SHALL continue to render the pinned course name with a Clear button on a single line, not as a horizontal row
|
||||
|
||||
#### Scenario: Mobile keeps stacked rows
|
||||
- **WHEN** the user views an unpinned elective set on mobile
|
||||
- **THEN** the course choices SHALL continue to render as vertically stacked full-width rows
|
||||
|
||||
### Requirement: Course button anatomy on desktop
|
||||
Each course button in a desktop horizontal row SHALL display: an info icon in the top-left when course description info is available, a "recommended" star indicator in the top-right when this course is the recommended choice for the set, the course name with a multi-line clamp (max 2-3 lines) and the full name available via the title attribute, and a row of spec ceiling tags at the bottom showing the specializations this course could contribute to.
|
||||
|
||||
#### Scenario: Button with all elements
|
||||
- **WHEN** a course on desktop is the recommended choice, has a description, and has spec qualifications
|
||||
- **THEN** the button SHALL show the info icon (top-left), the recommended star (top-right), the course name with line-clamp, and the spec ceiling tags at the bottom
|
||||
|
||||
#### Scenario: Course without info
|
||||
- **WHEN** a course on desktop has no description in `COURSE_DESCRIPTIONS`
|
||||
- **THEN** the button SHALL omit the info icon while keeping all other elements
|
||||
|
||||
#### Scenario: Course not recommended
|
||||
- **WHEN** a course on desktop is not the recommended choice for its set
|
||||
- **THEN** the button SHALL omit the recommended star while keeping all other elements
|
||||
|
||||
#### Scenario: Long course name truncation
|
||||
- **WHEN** a course name exceeds the line-clamp limit
|
||||
- **THEN** the visible text SHALL truncate with an ellipsis and the full name SHALL be available via the title attribute on hover
|
||||
|
||||
#### Scenario: Spec tag overflow within button
|
||||
- **WHEN** a course has more spec tags than fit in a single row inside the button at the current button width
|
||||
- **THEN** the spec tag row SHALL wrap to multiple lines within the button, and the button heights in the set SHALL flex-stretch to remain aligned
|
||||
|
||||
### Requirement: Course button states on desktop
|
||||
Course buttons on desktop SHALL communicate the following states with distinct styling: cancelled (strikethrough name, gray background, "Cancelled" footer text, non-clickable), already-selected-elsewhere (gray background, "Already selected" footer text, non-clickable), per-course searching (skeleton placeholder where spec tags would render), recommended (recommended star + visual emphasis), and required-for-spec (small amber footer note "Required for X" when the course is required for one or more specializations).
|
||||
|
||||
#### Scenario: Cancelled course button
|
||||
- **WHEN** a course in an unpinned set has `cancelled: true`
|
||||
- **THEN** its button SHALL render with strikethrough name, gray background, a "(Cancelled)" footer label, and SHALL not be clickable
|
||||
|
||||
#### Scenario: Already-selected course button
|
||||
- **WHEN** a course is in the disabled-because-pinned-elsewhere set
|
||||
- **THEN** its button SHALL render with gray background, an "(Already selected)" footer label, and SHALL not be clickable
|
||||
|
||||
#### Scenario: Per-course searching state
|
||||
- **WHEN** the decision tree analysis for a course is still in progress (the course's `evaluated` flag is false)
|
||||
- **THEN** the button SHALL render a pulsing skeleton band where the spec ceiling tags would otherwise appear, with all other content unchanged
|
||||
|
||||
#### Scenario: Required-for footer
|
||||
- **WHEN** a course is required for one or more specializations
|
||||
- **THEN** the button SHALL render a small amber "Required for X" footer beneath the spec tag row
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Inline decision tree ceiling per course option
|
||||
When decision tree analysis is available for an open elective set, each course option SHALL display its ceiling outcome (spec abbreviations) as small spec tags. On mobile, the spec tags SHALL render on the right side of each course row. On desktop, the spec tags SHALL render in a row along the bottom of each course button.
|
||||
|
||||
#### Scenario: Mobile ceiling data on right side
|
||||
- **WHEN** an open set has completed decision tree analysis on mobile and a course has ceiling specs (BNK, FIN, LCM)
|
||||
- **THEN** the course row SHALL show the course name on the left and tags for BNK, FIN, LCM on the right
|
||||
|
||||
#### Scenario: Desktop ceiling data along bottom
|
||||
- **WHEN** an open set has completed decision tree analysis on desktop and a course has ceiling specs (BNK, FIN, LCM)
|
||||
- **THEN** the course button SHALL show the course name with line-clamp at top and a row of tags for BNK, FIN, LCM along the bottom of the button
|
||||
|
||||
#### Scenario: Ceiling data not yet available
|
||||
- **WHEN** an open set's decision tree analysis is still computing
|
||||
- **THEN** the course buttons (mobile rows or desktop buttons) SHALL render without ceiling tags, and the set header SHALL show a subtle loading indicator
|
||||
|
||||
#### Scenario: Pinned set does not show ceiling
|
||||
- **WHEN** a set has a pinned course selection
|
||||
- **THEN** the set SHALL display the pinned course name without ceiling data on either mobile or desktop (same as current behavior)
|
||||
|
||||
### Requirement: High impact indicator on set header
|
||||
When a set has high impact (variance > 0 in ceiling outcomes), the set header SHALL display a "high impact" indicator. This applies on both mobile and desktop.
|
||||
|
||||
#### Scenario: High impact set on mobile or desktop
|
||||
- **WHEN** an open set's analysis shows impact > 0
|
||||
- **THEN** the set header SHALL display a "high impact" label next to the set name
|
||||
|
||||
### Requirement: No standalone decision tree section
|
||||
The standalone DecisionTree component at the bottom of the results dashboard SHALL be removed. All ceiling data SHALL be displayed inline within the course selection panel — on mobile as right-side row content, on desktop as bottom-of-button content.
|
||||
|
||||
#### Scenario: All tree data inline on mobile
|
||||
- **WHEN** the user views the course selection panel on mobile
|
||||
- **THEN** there SHALL be no separate "Decision Tree" heading or section; all ceiling outcomes appear within their respective elective set cards as right-side row content
|
||||
|
||||
#### Scenario: All tree data inline on desktop
|
||||
- **WHEN** the user views the Schedule column on desktop
|
||||
- **THEN** there SHALL be no separate "Decision Tree" heading or section; all ceiling outcomes appear within their respective elective set cards as bottom-of-button content
|
||||
@@ -0,0 +1,107 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Desktop horizontal specialization strip
|
||||
On desktop, the specialization panel SHALL render as a single-row horizontal strip of compact chips, one chip per specialization, ordered left-to-right by priority rank. Each chip SHALL display the rank number, the specialization's 3-letter abbreviation, and a micro credit progress bar. The chip's background color SHALL encode the specialization's status using the same four-color palette used for vertical rows on mobile (achieved, achievable, missing required, unreachable).
|
||||
|
||||
#### Scenario: Strip rendering on desktop
|
||||
- **WHEN** the user views the app on desktop
|
||||
- **THEN** all specializations SHALL render as a horizontal row of compact chips in priority order (highest priority leftmost)
|
||||
|
||||
#### Scenario: Chip status color
|
||||
- **WHEN** a specialization is in the "achieved" status
|
||||
- **THEN** its chip SHALL render with the achieved-status background color from `STATUS_STYLES`
|
||||
|
||||
#### Scenario: Chip content
|
||||
- **WHEN** a specialization chip renders on desktop
|
||||
- **THEN** the chip SHALL display the rank number, the 3-letter abbreviation (e.g., "FIN"), and a micro credit progress bar reflecting allocated credits versus the 9-credit threshold
|
||||
|
||||
#### Scenario: Strip overflow on narrow desktop viewports
|
||||
- **WHEN** the desktop viewport width is too narrow to fit all chips at their target width
|
||||
- **THEN** the strip SHALL allow horizontal scrolling within itself rather than wrap to multiple rows
|
||||
|
||||
### Requirement: Specialization chip hover popover
|
||||
On desktop, hovering or tapping a specialization chip SHALL open a popover anchored to the chip showing the full specialization name, the status word ("Achieved" / "Achievable" / "Missing Required" / "Unreachable"), the allocated/threshold credits in numeric form (e.g., "5.5 / 9.0"), and — for achieved specializations — the contributing-courses breakdown (each contributing course name and its credit amount). The popover SHALL stay open while the cursor is over either the chip or the popover, and SHALL close when the cursor leaves both.
|
||||
|
||||
#### Scenario: Hover opens popover
|
||||
- **WHEN** the user moves the cursor over a specialization chip on desktop
|
||||
- **THEN** a popover SHALL open anchored to the chip showing the full name, status word, and allocated/threshold credits
|
||||
|
||||
#### Scenario: Popover for achieved specialization
|
||||
- **WHEN** the user hovers a chip whose specialization is achieved
|
||||
- **THEN** the popover SHALL additionally list each contributing course's name and its credit amount
|
||||
|
||||
#### Scenario: Popover for non-achieved specialization
|
||||
- **WHEN** the user hovers a chip whose specialization is not in the achieved status
|
||||
- **THEN** the popover SHALL omit the contributing-courses breakdown but SHALL still show the full name, status word, and allocated/threshold credits
|
||||
|
||||
#### Scenario: Tap toggles popover on touch
|
||||
- **WHEN** the user taps a chip on a touch device using the desktop layout
|
||||
- **THEN** the popover SHALL toggle open or closed; tapping outside the chip and popover SHALL close it
|
||||
|
||||
#### Scenario: Popover stays open while cursor moves to popover
|
||||
- **WHEN** the user hovers a chip to open the popover and then moves the cursor onto the popover
|
||||
- **THEN** the popover SHALL remain open
|
||||
|
||||
#### Scenario: Popover does not clip at viewport edges
|
||||
- **WHEN** a chip near the right edge of the viewport is hovered
|
||||
- **THEN** the popover SHALL be repositioned horizontally so that it remains within the viewport
|
||||
- **WHEN** a chip is near the bottom of available space
|
||||
- **THEN** the popover SHALL flip above the chip if there is more space above than below
|
||||
|
||||
### Requirement: Horizontal drag-to-reorder on desktop
|
||||
On desktop, the specialization chip strip SHALL support drag-and-drop reordering using a left-to-right horizontal sorting strategy. Dragging a chip and dropping it at a new horizontal position SHALL update the priority ranking accordingly.
|
||||
|
||||
#### Scenario: Drag chip left or right
|
||||
- **WHEN** the user drags a specialization chip to a new horizontal position on desktop
|
||||
- **THEN** the priority ranking SHALL update to reflect the new left-to-right order (leftmost chip is highest priority)
|
||||
|
||||
#### Scenario: Mobile keeps vertical drag
|
||||
- **WHEN** the user drags a specialization on mobile
|
||||
- **THEN** reordering SHALL continue to use vertical drag semantics
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Specialization rows include credit progress
|
||||
On mobile, each specialization row in the ranking list SHALL display the credit progress bar and allocated/threshold credits alongside the rank, name, and status badge. The row layout SHALL be: reorder controls, rank number, name, credits (e.g. "7.5 / 9.0"), status badge, with a credit bar below. On desktop the equivalent information SHALL render in the chip + hover-popover combination defined in this capability rather than as full rows.
|
||||
|
||||
#### Scenario: Mobile row displays allocated credits and bar
|
||||
- **WHEN** a specialization has 7.5 allocated credits from pinned courses on mobile
|
||||
- **THEN** the row SHALL show "7.5 / 9.0" and a credit progress bar filled to 7.5/9.0
|
||||
|
||||
#### Scenario: Mobile row displays zero credits
|
||||
- **WHEN** a specialization has no allocated credits on mobile
|
||||
- **THEN** the row SHALL show "0 / 9.0" and an empty credit progress bar with the 9-credit threshold marker visible
|
||||
|
||||
#### Scenario: Desktop chip displays credit progress
|
||||
- **WHEN** a specialization has any allocated credits on desktop
|
||||
- **THEN** the chip SHALL show a micro credit bar reflecting the allocated proportion against the 9-credit threshold, and the popover SHALL display the numeric "X.X / 9.0" value
|
||||
|
||||
### Requirement: Expandable allocation breakdown
|
||||
On mobile, achieved specialization rows SHALL be tappable/clickable to expand and show the allocation breakdown (which courses contribute how many credits). On desktop, the allocation breakdown SHALL render inside the chip's hover popover for achieved specializations rather than via in-place expansion.
|
||||
|
||||
#### Scenario: Mobile tap to expand achieved spec
|
||||
- **WHEN** a user taps an achieved specialization row on mobile
|
||||
- **THEN** the row SHALL expand to show a list of contributing courses and their credit amounts
|
||||
|
||||
#### Scenario: Mobile tap to collapse
|
||||
- **WHEN** a user taps an already-expanded achieved specialization row on mobile
|
||||
- **THEN** the allocation breakdown SHALL collapse
|
||||
|
||||
#### Scenario: Mobile non-achieved specs are not expandable
|
||||
- **WHEN** a user taps a specialization that is not achieved on mobile
|
||||
- **THEN** nothing SHALL happen (no expand/collapse)
|
||||
|
||||
#### Scenario: Desktop allocation breakdown via popover
|
||||
- **WHEN** a user hovers an achieved specialization chip on desktop
|
||||
- **THEN** the chip's popover SHALL show the contributing-courses breakdown (each course name and its credit amount); no in-place expansion of the chip occurs
|
||||
|
||||
### Requirement: Achievement summary
|
||||
The panel SHALL display a summary count showing how many specializations are currently achieved. On mobile, the summary appears above the ranking list. On desktop, the summary appears in the specialization strip's header area (above or alongside the strip).
|
||||
|
||||
#### Scenario: Some achieved
|
||||
- **WHEN** 2 specializations are achieved
|
||||
- **THEN** the panel SHALL display "2 specializations achieved" (mobile: above the list; desktop: in the strip header)
|
||||
|
||||
#### Scenario: None achieved
|
||||
- **WHEN** no specializations are achieved
|
||||
- **THEN** the panel SHALL display "No specializations achieved yet" (mobile: above the list; desktop: in the strip header)
|
||||
@@ -0,0 +1,67 @@
|
||||
## 1. Layout reflow scaffolding
|
||||
|
||||
- [x] 1.1 In `app/src/App.tsx`, replace the desktop branch's 2-column grid (`340px 1fr`) with a vertical flex container holding: header section, mode toggle, mode comparison banner, specialization strip placeholder, progress strip placeholder, and a 1fr 2-column workspace (`1fr 1fr`) for plans/schedule.
|
||||
- [x] 1.2 Ensure each workspace column has `overflowY: auto; min-height: 0` and the parent grid has `flex: 1; min-height: 0` so independent scroll works inside the existing 100vh container.
|
||||
- [x] 1.3 Verify mobile branch JSX is byte-equivalent to today (no regressions to mobile stack, MobileStatusBanner / MobileCourseBanner refs, or scroll-into-view behavior).
|
||||
- [x] 1.4 Move the `CreditLegend` rendering out of the Schedule column. It will be invoked from the spec strip header in step 2.
|
||||
|
||||
## 2. Specialization horizontal strip
|
||||
|
||||
- [x] 2.1 In `app/src/components/SpecializationRanking.tsx`, branch on `useMediaQuery() === 'desktop'` (or the existing `isMobile` derivation if shared) to render a strip variant.
|
||||
- [x] 2.2 Implement the desktop strip container: single-row flex (`display: flex; gap: 6px; overflowX: auto`) so narrow viewports get a horizontal scrollbar on the strip rather than wrap.
|
||||
- [x] 2.3 Implement compact `Chip` element: ~70px wide, vertical stack of {rank number small, abbreviation bold, micro credit bar}. Background color from `STATUS_STYLES[status].bg`. Use the abbreviation field from `SPECIALIZATIONS`.
|
||||
- [x] 2.4 Reuse `CreditBar` rendering at smaller dimensions (height ~4px, no tick marks for the compact variant; only the threshold marker).
|
||||
- [x] 2.5 Replace the `verticalListSortingStrategy` import with `horizontalListSortingStrategy` in the desktop branch (keep vertical for mobile). Reuse the existing `DndContext`, sensors, and `arrayMove` handler.
|
||||
- [x] 2.6 Render the achievement summary ("X specializations achieved" / "No specializations achieved yet") in a strip header row above the chip strip on desktop. Right-align the `[▸ legend]` toggle.
|
||||
- [x] 2.7 Mount the existing `CreditLegend` content as the expandable panel triggered by `[▸ legend]`. The legend toggle and panel can be rendered directly in `App.tsx`'s spec-strip header area, or as a child of `SpecializationRanking`'s strip header — pick one and stay consistent.
|
||||
|
||||
## 3. Specialization chip hover popover
|
||||
|
||||
- [x] 3.1 Build a `SpecChipPopover` component (can live in `SpecializationRanking.tsx`) that takes `specId`, `anchorRect`, `result`, and an `onClose` handler.
|
||||
- [x] 3.2 Compute popover position with the same smart-flip logic as `CourseInfoPopover` (use `spaceBelow`, `spaceAbove`, clamp `left` to viewport).
|
||||
- [x] 3.3 Popover content: full spec name (bold), status word ("Achieved" / "Achievable" / "Missing Required" / "Unreachable") with same color as the badge today, allocated/threshold numeric (e.g., "5.5 / 9.0"), and — only when the spec is achieved — a list of contributing courses with their credit amounts (reuse the existing `AllocationBreakdown` logic).
|
||||
- [x] 3.4 Implement hover-open / hover-close with a 150ms close delay so the cursor can move from chip to popover. Mirror the pattern in `CourseSelection.tsx` (`hoverCloseTimer`, `cancelHoverClose`, `handleHoverOpen`, `handleHoverLeave`).
|
||||
- [x] 3.5 Implement tap-toggle for touch devices: `onClick` toggles open/closed; clicking outside closes (use the same `mousedown` outside-click handler the existing popover uses).
|
||||
- [x] 3.6 Open popover via `onMouseEnter` only when `window.matchMedia('(hover: hover)').matches` (consistent with how the course info popover handles touch).
|
||||
|
||||
## 4. Hoist search progress strip
|
||||
|
||||
- [x] 4.1 In `app/src/components/TopPlans.tsx`, remove the animated progress bar JSX and the percent/iteration sub-line. Keep the static "Search complete · N explored" / "Search incomplete · cap hit at N" status text in the plan header (it's a per-panel summary).
|
||||
- [x] 4.2 Adjust `TopPlansProps` if needed so the parent can still pass `progress` and `loading` (only consumed for the static status text now).
|
||||
- [x] 4.3 Create `app/src/components/SearchProgressStrip.tsx` exporting a component that takes `progress: { iterations; iterationsTotal } | null` and `loading: boolean` and renders the animated bar + iteration count when `loading && progress`. Render nothing otherwise (so it collapses to zero height when idle).
|
||||
- [x] 4.4 Render `SearchProgressStrip` in `App.tsx`'s desktop branch between the spec strip and the workspace columns.
|
||||
- [x] 4.5 On mobile, render the existing inline-progress-bar JSX inside `TopPlans` (gate it on `isMobile`). The desktop strip only renders when `!isMobile`.
|
||||
|
||||
## 5. Schedule horizontal course buttons
|
||||
|
||||
- [x] 5.1 In `app/src/components/CourseSelection.tsx`, change `ElectiveSet`'s unpinned branch container from `display: flex; flexDirection: column; gap: 4px` to `display: flex; gap: 4px` (no flex direction) on desktop. Keep the column layout on mobile via a `useMediaQuery` branch.
|
||||
- [x] 5.2 Each course button gets `flex: '1 1 0'` so buttons stretch to equal width within the row. Set `min-width: 0` on the button to allow text truncation.
|
||||
- [x] 5.3 Restructure the button content into top/middle/bottom rows: top row contains the info icon (left) and the recommended star (right). Middle is the course name with `WebkitLineClamp: 3`. Bottom is the spec ceiling tag row.
|
||||
- [x] 5.4 Add `title={course.name}` so the full name is available on hover for truncated names.
|
||||
- [x] 5.5 Allow the spec-tag row to wrap (`flex-wrap: wrap`); use `flex-grow: 1` on the wrapping container so button heights stretch to match the tallest button in the set.
|
||||
- [x] 5.6 Map cancelled and already-selected states to button styling: gray background, strikethrough name (cancelled only), tiny footer label "(Cancelled)" or "(Already selected)" below the spec-tag row.
|
||||
- [x] 5.7 Map per-course searching state to a pulsing skeleton band where the spec tags would render (reuse the `cell-pulse` keyframe).
|
||||
- [x] 5.8 Map the required-for-spec note to a small amber footer line below the spec tags.
|
||||
- [x] 5.9 Pinned-set branch is unchanged. Term headers (Spring/Summer/Fall) are unchanged.
|
||||
- [x] 5.10 Verify the existing `CourseInfoPopover` invocation (and its hover/click/keyboard handlers) still works inside the new button anatomy — the info icon remains a clickable child element with `stopPropagation`.
|
||||
|
||||
## 6. Wiring and styles
|
||||
|
||||
- [x] 6.1 Confirm `App.tsx` passes `searchProgress` and `treeLoading` down to `SearchProgressStrip` and `TopPlans` correctly with the new responsibilities split.
|
||||
- [x] 6.2 Confirm `useAppState` shape is unchanged (no state-machine edits needed for this refactor).
|
||||
- [x] 6.3 Re-check the `courseSectionRef` and `specSectionRef` IntersectionObserver hooks in `App.tsx`: the refs must still anchor to mobile-only sections (the floating MobileStatusBanner / MobileCourseBanner depend on them).
|
||||
|
||||
## 7. Verification
|
||||
|
||||
- [x] 7.1 Type-check (`npm run build` or `tsc --noEmit`) clean. _Note: vite build passes; `tsc` has pre-existing errors in `feasibility.ts`, `optimizer.ts`, `decisionTree.ts`, `appState.ts`, and an unused `scorer` prop in `CourseSelection.tsx` — all present at v1.3.3 baseline, none introduced by this change._
|
||||
- [x] 7.2 Lint (`npm run lint`) clean. _Note: same pre-existing 11 errors (setState-in-effect in `appState.ts`/`App.tsx`/`SpecializationRanking.tsx` SortableItem, unused vars in solver files, fast-refresh warning on existing `STATUS_STYLES` export). No new errors from this change._
|
||||
- [ ] 7.3 Manual desktop check at 1200px+ viewport: spec strip in single row, chip hover opens popover with correct content, drag-to-reorder works left-right, progress strip animates during search, 2 columns scroll independently, schedule blocks render horizontal button rows, all states render correctly (default / cancelled / already-selected / searching / recommended / required-for). _Deferred — Playwright browser binary not available in this sandbox; user should verify in their dev environment._
|
||||
- [ ] 7.4 Manual desktop check at narrower viewport (~1024px): chip strip gets internal horizontal scrollbar without wrapping; column split still readable; schedule buttons still legible at minimum width. _Deferred — same as 7.3._
|
||||
- [ ] 7.5 Manual mobile check (≤768px): vertical specialization list with arrows + drag, vertical-stacked schedule rows, progress bar inside Top Plans (not hoisted), MobileStatusBanner and MobileCourseBanner still appear when their respective sections scroll out of view. _Deferred — same as 7.3._
|
||||
- [x] 7.6 Existing unit tests pass (`npm test`). _Verified: 84 tests pass across 6 test files._
|
||||
|
||||
## 8. Release
|
||||
|
||||
- [x] 8.1 Bump version to `1.4.0` in `app/vite.config.ts`.
|
||||
- [x] 8.2 Add a `1.4.0` entry to `CHANGELOG.md` summarizing the desktop redesign (horizontal spec strip, hoisted progress, 2-col workspace, horizontal schedule buttons, mobile unchanged).
|
||||
- [x] 8.3 Build (`npm run build`) and verify the production bundle. _Verified via `npx vite build`: 403 KB JS bundle, 0.5 KB CSS, built in ~1.3s._
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-05-09
|
||||
@@ -0,0 +1,3 @@
|
||||
# j27-spec-update
|
||||
|
||||
Apply J27 specialization sheet (5/6/2026): add Healthcare specialization, mark Customer Insights cancelled, add cancelled Managing Growing Companies entry, rename and re-describe Social Media → Digital Marketing Strategy in Practice, add HCR qualifications to four existing courses.
|
||||
@@ -0,0 +1,75 @@
|
||||
## Context
|
||||
|
||||
The Stern J27 Specializations one-pager (5/6/2026) introduces structural changes that go beyond a simple data refresh:
|
||||
|
||||
- A new **Healthcare** specialization (HCR) — the first new specialization since the app's initial 14
|
||||
- Two cancelled-course bookkeeping items (Customer Insights becomes cancelled; Managing Growing Companies reappears as a cancelled placeholder)
|
||||
- A renamed elective (Social Media and Mobile Technology → Digital Marketing Strategy in Practice) with a substantively different syllabus, instructor lineup, and a new qualification (Healthcare)
|
||||
|
||||
The existing codebase already includes the full plumbing for `cancelled: true` (defined in `app/src/data/types.ts:22`, indexed in `app/src/data/lookups.ts:43-45`, excluded from solver via `app/src/state/appState.ts:94-95`, greyed-out in `app/src/components/CourseSelection.tsx:279`) but the prior change `replace-cancelled-course-with-innovation-design` chose hard-deletion over the flag. This change formalizes the flag as the standing pattern.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Bring data files into exact correspondence with the J27 sheet
|
||||
- Establish `cancelled: true` as the canonical pattern for cancelled courses (Approach B)
|
||||
- Make the data integrity test reflect *reachable* credits (excluding cancelled courses), so that a future cancellation triggers an obviously-failing reachability assertion
|
||||
- Verify the published per-spec credit totals from the CSV match the computed reachability table
|
||||
|
||||
**Non-Goals:**
|
||||
- No changes to the solver, lookups infrastructure, or React components — the cancelled flag is already honored end-to-end
|
||||
- No new color/iconography work for HCR beyond what falls out of the existing rendering of unknown specializations (if a missing color is observed during browser testing, address with a follow-up; do not block on it)
|
||||
- No data-migration support for older saved selections — the app does not persist user state across reloads
|
||||
- No support for multiple simultaneous cohorts; this is an in-place J27 update
|
||||
|
||||
## Decisions
|
||||
|
||||
### Approach B (cancelled flag), not hard-delete
|
||||
Use `cancelled: true` rather than removing entries. The flag exists, is fully wired, and matches the printed sheet 1:1 (which preserves the slot even when a course is cancelled). The prior delete-and-replace pattern was driven by lack of UI greying — that's no longer a constraint.
|
||||
|
||||
**Alternatives considered:** Hard-delete the cancelled entries, as in `replace-cancelled-course-with-innovation-design`. Rejected because the school's official sheet shows the cancelled slot, and preserving slot ordering helps students cross-reference printed materials.
|
||||
|
||||
### Add `sum2-managing-growing-companies` as a fresh cancelled entry
|
||||
Even though a previous change removed this id, the J27 sheet reintroduces it as a cancelled placeholder. We add it back with `cancelled: true` and empty qualifications. The id is reusable because no persisted state references it.
|
||||
|
||||
**Alternative considered:** Skip it (Summer Set 2 stays at 4 entries). Rejected because the printed sheet shows 5 slots; hiding one would create a discrepancy the user might flag as a bug.
|
||||
|
||||
### Update reachability test to exclude cancelled courses
|
||||
Modify `data.test.ts` so the per-spec "across sets" assertion filters out qualifications belonging to cancelled courses before counting distinct sets. This matches the CSV's published credit totals (BRM 12.5 = 5 sets × 2.5; MKT 15.0 = 6 sets × 2.5; HCR 10.0 = 4 sets × 2.5).
|
||||
|
||||
**Alternative considered:** Leave the test counting cancelled courses and accept that BRM/MKT reach-counts disagree with the published totals. Rejected — the test's stated purpose is to mirror the reachability table, and divergence makes regressions invisible.
|
||||
|
||||
### Do NOT change `coursesBySpec` to filter cancelled
|
||||
The lookup map keeps cancelled qualifications. The solver already excludes them via `appState`'s `excluded` set, and other consumers (or future debug tooling) may want the full picture. Filter at the consumer (test), not at the source.
|
||||
|
||||
**Alternative considered:** Strip cancelled entries from `coursesBySpec` directly. Rejected because that hides data from any future caller that might legitimately want to inspect cancelled courses, and the test is the only consumer that currently cares.
|
||||
|
||||
### Clear instructor for Digital Marketing course
|
||||
The new description (`digital-marketing.txt`) describes a substantively different course (MSKCC engagement, agentic AI) led by guest contributors from L'Oréal and Google Gemini, with no clear primary instructor named. Set `instructors: []` rather than carry forward Stewart Krentzman from the old syllabus, which would be misleading.
|
||||
|
||||
**Alternative considered:** Retain the prior instructor name. Rejected — the course content has changed materially, and showing a stale instructor is worse than showing none.
|
||||
|
||||
### Healthcare gets no required-course gate
|
||||
The CSV asterisk convention (used for required courses) does not appear on any HCR-qualifying course. Healthcare therefore has no `requiredCourseId`.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **Reachability test now depends on cancelled flag** → Mitigation: the test continues to assert all 14 (now 15) specs; if the cancelled flag is accidentally cleared, the test's set count for BRM/MKT will jump back up and fail
|
||||
- **HCR may render with no themed color in legend** → Mitigation: verify in browser; if a default color is unacceptable, file a follow-up rather than blocking this change
|
||||
- **Reusing the `sum2-managing-growing-companies` id** → Risk is low (no persistence); document the reuse in the changelog so a future bisecting developer is not surprised
|
||||
- **CSV header dates the sheet 5/6/2026 (one day before today's date)** → No risk; just confirms the sheet is current
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. Land data changes in a single commit (or one commit per file, whichever fits the repo's PR style)
|
||||
2. Run `pnpm test` (or `npm test`) and confirm all data integrity tests pass with updated expectations
|
||||
3. Browser-verify: load the app, observe HCR appears in the specialization list, the two cancelled courses render greyed, the renamed Digital Marketing course shows the new description on info-popover hover
|
||||
4. Bump `__APP_VERSION__` to `1.2.2` (patch bump consistent with prior data-only updates) and `__APP_VERSION_DATE__` in `app/vite.config.ts`
|
||||
5. Add a `1.2.2` entry to `CHANGELOG.md`
|
||||
|
||||
Rollback: revert the data file changes; no schema or build changes to undo.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- **Lead instructor for Digital Marketing Strategy in Practice** — the description names guest speakers but no primary instructor; clear for now and refresh if the school publishes one
|
||||
- **HCR display color/order** — verify in browser; address with a follow-up if the default rendering looks wrong
|
||||
@@ -0,0 +1,37 @@
|
||||
## Why
|
||||
|
||||
The Stern School published an updated J27 Specializations one-pager (dated 5/6/2026) that introduces a new Healthcare specialization, cancels the Customer Insights elective, lists Managing Growing Companies as a cancelled placeholder in Summer Set 2, renames Social Media and Mobile Technology to Digital Marketing Strategy in Practice (with a substantively different course description and focus), and cross-lists four existing courses to the new Healthcare specialization. The solver's data files must match the official sheet so students rely on accurate availability information.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Add **Healthcare (HCR)** specialization (10 credits, no required course)
|
||||
- Add HCR qualification to four existing courses:
|
||||
- `spr2-health-medical` (Business of Health & Medical Care)
|
||||
- `spr3-analytics-ml` (Analytics & Machine Learning for Managers)
|
||||
- `sum2-social-media` (formerly Social Media and Mobile Technology)
|
||||
- `fall1-managing-change` (Managing Change)
|
||||
- Rename `sum2-social-media` to **Digital Marketing Strategy in Practice** and replace its course description with the new MSKCC-anchored content (provided in `digital-marketing.txt`); clear instructor field pending confirmation of new lead instructor
|
||||
- Mark **`spr5-customer-insights`** as `cancelled: true` (retain entry; do not delete) — switching from prior delete-and-replace pattern to the existing-but-unused `cancelled` flag pattern (Approach B)
|
||||
- Add a new cancelled-only entry **`sum2-managing-growing-companies`** ("Managing Growing Companies") to Summer Set 2 with `cancelled: true` and no qualifications, restoring the printed sheet's 5-slot listing
|
||||
- Update test assertions in `app/src/data/__tests__/data.test.ts` to reflect the 15th specialization, the 5-entry sum2 set, and revised per-spec set counts (BRM, MKT lose `spr5-customer-insights`)
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
_None — this is a data refresh that uses existing capabilities (`course-data`, `specialization-ranking`)._
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `course-data`: Add Healthcare specialization, four HCR cross-listings, the renamed Digital Marketing course, and two cancelled-flag entries. Adopt `cancelled: true` (Approach B) as the standing pattern for cancelled courses, superseding the prior delete-and-replace approach.
|
||||
|
||||
## Impact
|
||||
|
||||
- `app/src/data/specializations.ts` — add HCR entry
|
||||
- `app/src/data/courses.ts` — five qualification/name edits, one new cancelled entry, one cancelled-flag edit
|
||||
- `app/src/data/electiveSets.ts` — append `sum2-managing-growing-companies` to `sum2.courseIds`
|
||||
- `app/src/data/courseDescriptions.ts` — replace `sum2-social-media` description; clear instructors
|
||||
- `app/src/data/__tests__/data.test.ts` — update specialization count (14→15), course count (46→47), and per-spec/STR-marker assertions
|
||||
- `app/vite.config.ts` — bump `__APP_VERSION__` and `__APP_VERSION_DATE__`
|
||||
- `CHANGELOG.md` — add release entry
|
||||
- No code changes to solver, lookups, or UI: existing `cancelled` plumbing in `lookups.ts:43-50`, `appState.ts:94-95`, and `CourseSelection.tsx:279` already handles greying-out and solver exclusion
|
||||
@@ -0,0 +1,117 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Healthcare specialization
|
||||
The system SHALL include "Healthcare" (abbreviation `HCR`) as a defined specialization with no required course gate.
|
||||
|
||||
#### Scenario: Healthcare appears in specialization list
|
||||
- **WHEN** the data module is loaded
|
||||
- **THEN** a specialization with id `HCR`, name "Healthcare", and abbreviation `HCR` is present in `SPECIALIZATIONS`
|
||||
|
||||
#### Scenario: Healthcare has no required course
|
||||
- **WHEN** inspecting the Healthcare specialization
|
||||
- **THEN** its `requiredCourseId` is undefined
|
||||
|
||||
#### Scenario: Healthcare reaches four elective sets
|
||||
- **WHEN** counting the distinct elective sets containing at least one non-cancelled HCR-qualifying course
|
||||
- **THEN** the count is exactly 4 (`spr2`, `spr3`, `sum2`, `fall1`), yielding 10.0 credits available (4 × 2.5)
|
||||
|
||||
### Requirement: Healthcare cross-listings on existing courses
|
||||
The system SHALL add Healthcare (`HCR`, standard marker) as an additional qualification on four existing courses without removing their prior qualifications.
|
||||
|
||||
#### Scenario: Business of Health & Medical Care qualifies for Healthcare
|
||||
- **WHEN** inspecting `spr2-health-medical` qualifications
|
||||
- **THEN** the list includes both `STR` (S2) and `HCR` (standard)
|
||||
|
||||
#### Scenario: Analytics & Machine Learning for Managers qualifies for Healthcare
|
||||
- **WHEN** inspecting `spr3-analytics-ml` qualifications
|
||||
- **THEN** the list includes both `MTO` (standard) and `HCR` (standard)
|
||||
|
||||
#### Scenario: Digital Marketing Strategy in Practice qualifies for Healthcare
|
||||
- **WHEN** inspecting `sum2-social-media` qualifications
|
||||
- **THEN** the list includes `BRM`, `EMT`, `MKT`, and `HCR` (all standard)
|
||||
|
||||
#### Scenario: Managing Change qualifies for Healthcare
|
||||
- **WHEN** inspecting `fall1-managing-change` qualifications
|
||||
- **THEN** the list includes `LCM`, `MGT`, `STR` (S2), and `HCR` (standard)
|
||||
|
||||
### Requirement: Cancelled courses preserved with cancelled flag
|
||||
The system SHALL retain cancelled course entries in the course data with `cancelled: true` rather than deleting them. Cancelled courses SHALL NOT be considered selectable, SHALL be excluded from solver computations, and SHALL render in the UI as visibly disabled.
|
||||
|
||||
#### Scenario: Customer Insights is marked cancelled
|
||||
- **WHEN** inspecting `spr5-customer-insights`
|
||||
- **THEN** the course has `cancelled: true` and remains a member of `spr5`
|
||||
|
||||
#### Scenario: Managing Growing Companies present as cancelled placeholder in Summer Set 2
|
||||
- **WHEN** inspecting Summer Elective Set 2 (`sum2`)
|
||||
- **THEN** it contains exactly five course entries, one of which is `sum2-managing-growing-companies` with `cancelled: true` and an empty qualifications list
|
||||
|
||||
#### Scenario: Cancelled courses excluded from solver
|
||||
- **WHEN** the solver computes specialization assignments
|
||||
- **THEN** no cancelled course id contributes credits toward any specialization
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Course definitions
|
||||
The system SHALL define exactly 47 courses. Each course SHALL have an ID, display name, and the ID of the elective set it belongs to. Courses MAY carry a `cancelled: true` flag indicating the offering has been withdrawn.
|
||||
|
||||
#### Scenario: Course count
|
||||
- **WHEN** the data module is loaded
|
||||
- **THEN** exactly 47 courses are defined
|
||||
|
||||
#### Scenario: Each course belongs to one set
|
||||
- **WHEN** iterating all courses
|
||||
- **THEN** every course references a valid elective set ID, and the set's course list includes that course
|
||||
|
||||
#### Scenario: Cancelled courses are flagged, not deleted
|
||||
- **WHEN** filtering courses by `cancelled === true`
|
||||
- **THEN** the result includes both `spr5-customer-insights` and `sum2-managing-growing-companies`
|
||||
|
||||
### Requirement: Specialization definitions
|
||||
The system SHALL define exactly 15 specializations. Each specialization SHALL have an ID, display name, and abbreviation. Specializations with a required course gate SHALL reference the required course ID.
|
||||
|
||||
#### Scenario: Specialization count
|
||||
- **WHEN** the data module is loaded
|
||||
- **THEN** exactly 15 specializations are defined
|
||||
|
||||
#### Scenario: Healthcare is included
|
||||
- **WHEN** searching specializations for id `HCR`
|
||||
- **THEN** a specialization named "Healthcare" is found
|
||||
|
||||
#### Scenario: Required course mappings unchanged
|
||||
- **WHEN** inspecting specializations with required courses
|
||||
- **THEN** exactly 4 specializations have required course gates: SBI → `spr4-sustainability`, ENT → `spr4-foundations-entrepreneurship`, EMT → `sum3-entertainment-media`, BRM → `fall4-brand-strategy`
|
||||
|
||||
### Requirement: Course-specialization qualification matrix
|
||||
Each course SHALL declare which specializations it qualifies for, with a marker type of standard (■), S1, or S2. Courses with no qualifying specializations (including cancelled courses) SHALL have an empty qualification list. Reachability counts SHALL exclude qualifications belonging to courses flagged `cancelled`.
|
||||
|
||||
#### Scenario: Marker types
|
||||
- **WHEN** inspecting course qualifications
|
||||
- **THEN** every qualification entry uses one of three marker types: standard, S1, or S2
|
||||
|
||||
#### Scenario: Strategy markers
|
||||
- **WHEN** counting Strategy-qualifying courses across all (non-cancelled) courses
|
||||
- **THEN** exactly 9 courses have S1 markers and exactly 8 courses have S2 markers
|
||||
|
||||
#### Scenario: Reachable distinct-set counts per specialization
|
||||
- **WHEN** counting distinct elective sets containing at least one non-cancelled course qualifying for each specialization
|
||||
- **THEN** the counts are: MGT 11, STR 9, LCM 9, FIN 9, CRF 8, MKT 6, BNK 6, BRM 5, FIM 6, MTO 6, GLB 5, EMT 4, ENT 4, SBI 4, HCR 4
|
||||
- **AND** these counts represent raw distinct-set reachability, not the CSV's published credit totals (which additionally apply S1/S2 and shared-course rules — out of scope for this assertion)
|
||||
|
||||
### Requirement: Renamed Digital Marketing course
|
||||
The system SHALL display course `sum2-social-media` with the name "Digital Marketing Strategy in Practice" and the updated description anchored on the Memorial Sloan Kettering Cancer Center engagement. The course id SHALL remain `sum2-social-media` (unchanged) so existing lookup tables and tests continue to resolve.
|
||||
|
||||
#### Scenario: Display name updated
|
||||
- **WHEN** inspecting the course with id `sum2-social-media`
|
||||
- **THEN** its `name` is "Digital Marketing Strategy in Practice"
|
||||
|
||||
#### Scenario: Description reflects MSKCC engagement
|
||||
- **WHEN** the user opens the course info popover for `sum2-social-media`
|
||||
- **THEN** the description references Memorial Sloan Kettering Cancer Center, agentic AI, and digital strategy practice
|
||||
|
||||
#### Scenario: Instructor cleared pending confirmation
|
||||
- **WHEN** inspecting the course's instructor list in `COURSE_DESCRIPTIONS`
|
||||
- **THEN** the `instructors` array is empty
|
||||
|
||||
## REMOVED Requirements
|
||||
|
||||
_None. The "Managing Growing Companies" id, previously removed by `replace-cancelled-course-with-innovation-design`, is reintroduced as a cancelled placeholder per the J27 sheet — see ADDED Requirements above._
|
||||
@@ -0,0 +1,52 @@
|
||||
## 1. Specialization Data
|
||||
|
||||
- [x] 1.1 Add `{ id: 'HCR', name: 'Healthcare', abbreviation: 'HCR' }` to `SPECIALIZATIONS` in `app/src/data/specializations.ts` (no `requiredCourseId`)
|
||||
|
||||
## 2. Course Data
|
||||
|
||||
- [x] 2.1 Add `{ specId: 'HCR', marker: 'standard' }` to qualifications of `spr2-health-medical` in `app/src/data/courses.ts`
|
||||
- [x] 2.2 Add `{ specId: 'HCR', marker: 'standard' }` to qualifications of `spr3-analytics-ml`
|
||||
- [x] 2.3 Rename `sum2-social-media` `name` to `'Digital Marketing Strategy in Practice'` and add `{ specId: 'HCR', marker: 'standard' }` to its qualifications
|
||||
- [x] 2.4 Add `{ specId: 'HCR', marker: 'standard' }` to qualifications of `fall1-managing-change`
|
||||
- [x] 2.5 Add `cancelled: true` to `spr5-customer-insights`; leave its `qualifications` array unchanged
|
||||
- [x] 2.6 Append a new course entry `{ id: 'sum2-managing-growing-companies', name: 'Managing Growing Companies', setId: 'sum2', qualifications: [], cancelled: true }` to `COURSES`
|
||||
|
||||
## 3. Elective Set Membership
|
||||
|
||||
- [x] 3.1 In `app/src/data/electiveSets.ts`, append `'sum2-managing-growing-companies'` to `sum2.courseIds` (placement: end of list, matching the order of the printed J27 sheet)
|
||||
|
||||
## 4. Course Description
|
||||
|
||||
- [x] 4.1 In `app/src/data/courseDescriptions.ts`, replace the value at key `'sum2-social-media'` with the description text from `digital-marketing.txt` (paragraphs joined with `\n\n`); set `instructors: []`
|
||||
|
||||
## 5. Lookup Behavior Verification
|
||||
|
||||
- [x] 5.1 Confirm `coursesBySpec` in `app/src/data/lookups.ts` continues to include cancelled-course qualifications (no code change required; the test asserts the consumer-side filter, not the lookup itself)
|
||||
|
||||
## 6. Test Updates
|
||||
|
||||
- [x] 6.1 Update `app/src/data/__tests__/data.test.ts`: change `expect(COURSES.length).toBe(46)` to `47`
|
||||
- [x] 6.2 Change `expect(SPECIALIZATIONS.length).toBe(14)` to `15`
|
||||
- [x] 6.3 In the "across sets" describe block, change the helper to filter out qualifications belonging to cancelled courses before counting distinct sets (read `cancelled` via `COURSES.find(...)`)
|
||||
- [x] 6.4 Update `expectedAcrossSets` map to: MGT 11, STR 9, LCM 9, FIN 9, CRF 8, MKT **6** (was 7), BNK 6, BRM **5** (was 6), FIM 6, MTO 6, GLB 5, EMT 4, ENT 4, SBI 4, **HCR 4** (new). Only BRM and MKT decrement (cancelled `spr5-customer-insights` removes spr5 from both); HCR is added. All other values unchanged. Note: this test counts raw distinct sets, not CSV-published credit totals (which apply S1/S2 and shared-course rules); do not "correct" other values to match the CSV.
|
||||
- [x] 6.5 Confirm STR S1=9 / S2=8 marker counts test still passes unchanged (no STR markers added or removed)
|
||||
- [x] 6.6 Run `npm test --prefix app` (or the project's preferred command) and confirm all data tests pass
|
||||
|
||||
## 7. Browser Verification
|
||||
|
||||
- [x] 7.1 Start dev server (`npm run dev --prefix app`) and load the app
|
||||
- [x] 7.2 Confirm "Healthcare" appears in the specialization list/legend
|
||||
- [x] 7.3 Confirm `spr5-customer-insights` ("Customer Insights") renders greyed/disabled in Spring Set 5
|
||||
- [x] 7.4 Confirm `sum2-managing-growing-companies` ("Managing Growing Companies") renders greyed/disabled as the 5th entry in Summer Set 2
|
||||
- [x] 7.5 Confirm `sum2-social-media` displays as "Digital Marketing Strategy in Practice"; open its info popover and verify the new MSKCC-anchored description appears with no instructor listed
|
||||
- [x] 7.6 Pick a Healthcare-qualifying course (e.g., Managing Change), pin it, and confirm Healthcare appears in the resulting specialization analysis
|
||||
- [x] 7.7 Note any rendering issue with HCR (missing color/legend entry) and capture for follow-up if found
|
||||
|
||||
## 8. Version and Changelog
|
||||
|
||||
- [x] 8.1 Bump `__APP_VERSION__` to `1.2.2` and update `__APP_VERSION_DATE__` to today's date in `app/vite.config.ts`
|
||||
- [x] 8.2 Add a `## [1.2.2]` entry to `CHANGELOG.md` describing: new Healthcare specialization, four HCR cross-listings, Digital Marketing Strategy in Practice rename + new description, two cancelled-course entries (Customer Insights, Managing Growing Companies), and adoption of the `cancelled` flag pattern
|
||||
|
||||
## 9. Cleanup
|
||||
|
||||
- [x] 9.1 Delete `J27 Specializations.csv` and `digital-marketing.txt` from the repo root (these were drop-in inputs; data has been migrated into source)
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-05-10
|
||||
@@ -0,0 +1,111 @@
|
||||
## Context
|
||||
|
||||
`useAppState` runs a decision-tree search via a debounced worker effect to populate `topPlans`. When `openSetIds.length === 0` (every set pinned), the effect short-circuits at `appState.ts:185-192`:
|
||||
|
||||
```ts
|
||||
if (openSetIds.length === 0) {
|
||||
setTreeResults([]);
|
||||
setTopPlans([]);
|
||||
setTopPlansPartial(false);
|
||||
setSearchProgress(null);
|
||||
setTreeLoading(false);
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
Meanwhile, `optimizationResult` (a separate `useMemo` of `optimize(...)`) IS computed for the same pinned set, and the spec strip shows the correct achievement count. The two views drift out of sync exactly when the user has just finished selecting their courses — the moment when "what did I end up with?" feedback matters most.
|
||||
|
||||
`TopPlans.tsx:41` further filters out plans with no achievement (`visible = plans.filter(p => p.achievedSpecs.length > 0)`), and renders "No plans yet achieve a specialization…" (lines 100-104) when nothing is visible. So even if we fed a synthesized plan with zero achievements through, today's filter would still hide it.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- When all sets are pinned, Top Plans shows the user's completed plan with its actual achievement count
|
||||
- Header reads "Your Plan" so the singular plan doesn't feel like a search result
|
||||
- A 0-spec completed plan still renders (the courses are visible; the count is honest)
|
||||
- No regression in the open-sets-> 0 cases (search-driven top-K, partial cache hits, streaming improvements)
|
||||
|
||||
**Non-Goals:**
|
||||
- New `PlanRow` styling for the completed-plan case
|
||||
- Removing or hiding the adopt-button (every set is already pinned, so the button is a no-op but harmless)
|
||||
- Restructuring the search progress strip
|
||||
- Caching the synthesized plan (it's free to compute on every render)
|
||||
|
||||
## Decisions
|
||||
|
||||
### Synthesize a single `PlanOutcome` in the appState early return
|
||||
|
||||
Replace the `setTopPlans([])` line with:
|
||||
|
||||
```ts
|
||||
const scorer = makePriorityScorer(state.ranking);
|
||||
const completed: PlanOutcome = {
|
||||
courseAssignments: pinnedAssignments,
|
||||
achievedSpecs: optimizationResult.achieved,
|
||||
priorityScore: scorer(optimizationResult.achieved),
|
||||
};
|
||||
setTopPlans([completed]);
|
||||
```
|
||||
|
||||
`pinnedAssignments` is already computed earlier in the hook. `optimizationResult.achieved` is already available. `makePriorityScorer` is already imported (used elsewhere in the solver layer, can be imported here cheaply).
|
||||
|
||||
**Alternative considered:** Build a parallel "completed plan" panel component and switch on `openSetIds.length === 0`. Rejected — the existing `PlanRow` already renders exactly the data we need. Adding a parallel layout is more code and more drift surface for one edge case.
|
||||
|
||||
### Drop the `achievedSpecs.length > 0` filter when there's a single completed plan
|
||||
|
||||
`TopPlans.tsx` currently does:
|
||||
|
||||
```ts
|
||||
const visible = plans.filter((p) => p.achievedSpecs.length > 0);
|
||||
```
|
||||
|
||||
For the all-pinned case, the synthesized plan must render even when the achievement is empty. The cleanest signal is to detect "is this a completed plan" by inspecting `pinnedCourses` (every elective set has a pinned course), and skip the filter in that case:
|
||||
|
||||
```ts
|
||||
const allPinned = ELECTIVE_SETS.every((s) => pinnedCourses[s.id]);
|
||||
const visible = allPinned ? plans : plans.filter((p) => p.achievedSpecs.length > 0);
|
||||
```
|
||||
|
||||
`pinnedCourses` is already a prop on `TopPlans`. No new wiring.
|
||||
|
||||
**Alternative considered:** Drop the filter unconditionally. Rejected — when the worker is mid-search and only emitted low-achievement candidates, the existing "Searching for high-priority plans…" placeholder is the right UX. The filter exists for a reason; we just need to gate it on whether selection is complete.
|
||||
|
||||
### Header copy: "Your Plan" when complete, "Top Plans" otherwise
|
||||
|
||||
```ts
|
||||
const heading = allPinned ? 'Your Plan' : 'Top Plans';
|
||||
```
|
||||
|
||||
The "ranked by specs achieved" subtitle stays on the multi-plan path. The single-plan path drops it (one plan can't be ranked).
|
||||
|
||||
### Empty placeholder copy update
|
||||
|
||||
When `!loading && visible.length === 0` and `allPinned` is true, the existing "No plans yet achieve a specialization…" message would be misleading (selection IS complete). Since the synthesis above guarantees `visible.length === 1` in the all-pinned branch, this placeholder will never fire in that path. No copy change needed there.
|
||||
|
||||
### Adopt-button behavior
|
||||
|
||||
The adopt button calls `onAdopt(plan.courseAssignments)`, which dispatches `pinCourse` for every set. When everything is already pinned, this is a no-op (idempotent). Leaving it visible is harmless. Hiding it would add a special case for no benefit.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **Synthesized plan vs. eventual full search** → No risk. The all-pinned branch returns before the search effect runs, so there's no race where the worker overwrites the synthesized plan.
|
||||
- **`allPinned` check uses `ELECTIVE_SETS.every(...)`** → Imports `ELECTIVE_SETS` into `TopPlans.tsx`. Already imported there (used for `setNameById`). No new dependency.
|
||||
- **Synthesizing on every render** → `useMemo` not strictly required (the synthesis is cheap), but the existing pattern in `useAppState` uses `setTopPlans` once per effect run, which is fine.
|
||||
- **Unpin-after-complete UX** → When the user unpins a single course after completing the plan, `openSetIds.length` becomes 1, the effect re-runs as a normal partial-cache search, and the synthesized plan path is no longer used. The cached subset (if any) renders immediately, exactly as today. Verified by inspection — no special handling needed.
|
||||
- **External-credits achievement** → `optimizationResult.achieved` is already external-credit-aware (per the prior change). No additional plumbing.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
Single-PR change. No data migration. No persisted state changes.
|
||||
|
||||
1. Update `useAppState` to synthesize the completed plan in the all-pinned branch.
|
||||
2. Update `TopPlans` to detect the all-pinned case and switch the header copy and visibility filter.
|
||||
3. Browser verify: pin all 12 sets, watch Top Plans render the completed plan with the same achievement count as the spec strip. Unpin one set: search resumes normally.
|
||||
4. Bump version to `1.5.1`; CHANGELOG entry.
|
||||
|
||||
Rollback: revert. v1.5.0 behavior restored.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Should the adopt button on the synthesized plan be replaced with a "Reset" or hidden? Defer to UI polish — keeping it inert is the smallest change.
|
||||
- Should the schedule panel get a "plan complete" affordance too? Out of scope for this change; could be a follow-up.
|
||||
@@ -0,0 +1,30 @@
|
||||
## Why
|
||||
|
||||
When a student pins a course in every elective set, the Top Plans panel goes blank and shows the placeholder "No plans yet achieve a specialization with the current pinned courses." This is misleading: the optimizer has already computed achievement (visible in the spec strip), but the Top Plans state is short-circuited to `[]` because no decision-tree search runs when there are no open sets. The student's completed plan IS the only plan — it should be the one rendered.
|
||||
|
||||
## What Changes
|
||||
|
||||
- When `openSetIds.length === 0`, synthesize a single `PlanOutcome` from the pinned assignments + `optimizationResult.achieved` + the priority scorer, and surface it through the existing `topPlans` state slice instead of clearing the slice.
|
||||
- Render the synthesized plan unconditionally (no `achievedSpecs.length > 0` filter) so a 0-spec completed plan still shows the courses-and-zero-achievements summary rather than the search placeholder.
|
||||
- Header copy switches to "Your Plan" (singular) when `openSetIds.length === 0`. Otherwise stays "Top Plans".
|
||||
- Leaf cache, search progress strip, "ranked by specs achieved" subtitle, and the search-complete/incomplete static text remain unchanged.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
_None._
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `optimization-engine`: when all elective sets are pinned, `useAppState` SHALL emit a single synthesized `PlanOutcome` rather than clearing the top-K slice.
|
||||
- `unified-specialization-panel`: the Top Plans panel SHALL render a synthesized completed plan with header copy "Your Plan" and SHALL NOT filter out a single completed plan with zero achievements.
|
||||
|
||||
## Impact
|
||||
|
||||
- `app/src/state/appState.ts` — replace the `setTopPlans([])` early return with a synthesized one-element top-K when `openSetIds.length === 0`. Use `makePriorityScorer(state.ranking)(achieved)` for the score.
|
||||
- `app/src/components/TopPlans.tsx` — when `plans.length === 1` and the only plan's `courseAssignments` covers every elective set, render it as "Your Plan" (header copy). Drop the `length > 0` visibility filter for that single-plan case so a 0-spec completed plan still renders. Other rendering paths unchanged.
|
||||
- No new tests strictly required, but a state-layer assertion for the synthesis would be nice. Existing 97 tests must continue to pass.
|
||||
- `app/vite.config.ts` — patch version bump (e.g., `1.5.1`).
|
||||
- `CHANGELOG.md` — release entry.
|
||||
- No data-file changes.
|
||||
@@ -0,0 +1,23 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Synthesized completed plan when all sets are pinned
|
||||
When `openSetIds.length === 0` (every elective set has a pinned course), `useAppState` SHALL emit a single-element `topPlans` array containing a synthesized `PlanOutcome` rather than clearing the slice. The synthesized plan's fields SHALL be:
|
||||
|
||||
- `courseAssignments`: the `pinnedAssignments` map (setId → courseId for every elective set)
|
||||
- `achievedSpecs`: `optimizationResult.achieved` (the optimizer's output for the same selection)
|
||||
- `priorityScore`: `makePriorityScorer(state.ranking)(achievedSpecs)`
|
||||
|
||||
The other state slices (`treeResults`, `topPlansPartial`, `searchProgress`, `treeLoading`) SHALL remain in their cleared/null state for this branch — no decision-tree search runs when there are no open sets.
|
||||
|
||||
#### Scenario: Top Plans state populates after every set is pinned
|
||||
- **WHEN** the user pins a course in the final remaining open set
|
||||
- **THEN** `topPlans` SHALL contain exactly one element whose `courseAssignments` matches the user's pinned selection
|
||||
- **AND** that element's `achievedSpecs` SHALL equal `optimizationResult.achieved`
|
||||
|
||||
#### Scenario: Search progress remains null when selection is complete
|
||||
- **WHEN** every elective set is pinned
|
||||
- **THEN** `searchProgress` SHALL be `null` and `treeLoading` SHALL be `false`
|
||||
|
||||
#### Scenario: Unpinning resumes normal search
|
||||
- **WHEN** the user unpins one course after the synthesized state was emitted
|
||||
- **THEN** the search effect SHALL run normally (cache filter, partial worker spawn) and `topPlans` SHALL reflect the cached subset and any streamed improvements
|
||||
@@ -0,0 +1,32 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Top Plans renders the synthesized completed plan
|
||||
The `TopPlans` panel SHALL detect the all-pinned state by checking that every elective set in `ELECTIVE_SETS` has a non-null `pinnedCourses[setId]`. In that state, the panel SHALL bypass the existing `achievedSpecs.length > 0` visibility filter and render every plan in the input list (which is guaranteed to be exactly one synthesized plan).
|
||||
|
||||
#### Scenario: Completed plan with achievements renders normally
|
||||
- **WHEN** the user has pinned every set and the synthesized plan reports 3 achieved specs
|
||||
- **THEN** the panel SHALL render a single `PlanRow` showing those 3 specs and the 12 pinned courses
|
||||
|
||||
#### Scenario: Completed plan with zero achievements still renders
|
||||
- **WHEN** the user has pinned every set but the achieved set is empty
|
||||
- **THEN** the panel SHALL render a single `PlanRow` showing `0` achievements and the 12 pinned courses
|
||||
- **AND** the panel SHALL NOT show the "No plans yet achieve a specialization…" placeholder
|
||||
|
||||
### Requirement: Top Plans header reflects single completed plan
|
||||
When the all-pinned state is detected, the panel header SHALL read "Your Plan" instead of "Top Plans". The "ranked by specs achieved" subtitle SHALL be omitted when only the single completed plan is shown.
|
||||
|
||||
#### Scenario: Header copy switches when selection is complete
|
||||
- **WHEN** every elective set is pinned
|
||||
- **THEN** the panel header SHALL display "Your Plan"
|
||||
- **AND** the "ranked by specs achieved" subtitle SHALL NOT appear
|
||||
|
||||
#### Scenario: Header copy reverts on unpin
|
||||
- **WHEN** the user unpins a course after a completed-plan state
|
||||
- **THEN** the panel header SHALL revert to "Top Plans" and the subtitle SHALL reappear once the multi-plan list is populated
|
||||
|
||||
### Requirement: Adopt button remains harmless on the synthesized plan
|
||||
The adopt button on the synthesized plan SHALL remain interactive. Clicking it SHALL be a no-op in effect (every set is already pinned to that course) but SHALL NOT throw or otherwise misbehave.
|
||||
|
||||
#### Scenario: Adopt on completed plan
|
||||
- **WHEN** the user clicks adopt on the synthesized completed plan
|
||||
- **THEN** the application state SHALL remain unchanged and no error SHALL occur
|
||||
@@ -0,0 +1,24 @@
|
||||
## 1. State synthesis
|
||||
|
||||
- [x] 1.1 In `app/src/state/appState.ts`, import `makePriorityScorer` from `../solver/priority`
|
||||
- [x] 1.2 Replace the `setTopPlans([])` line in the `openSetIds.length === 0` early-return branch with a synthesized single-element top-K: `{ courseAssignments: pinnedAssignments, achievedSpecs: optimizationResult.achieved, priorityScore: scorer(optimizationResult.achieved) }`. Keep `setTreeResults([])`, `setTopPlansPartial(false)`, `setSearchProgress(null)`, `setTreeLoading(false)` as-is
|
||||
- [x] 1.3 Confirm the synthesized state respects external credits implicitly via `optimizationResult.achieved` (no extra plumbing needed)
|
||||
|
||||
## 2. UI: detect all-pinned and render
|
||||
|
||||
- [x] 2.1 In `app/src/components/TopPlans.tsx`, compute `const allPinned = ELECTIVE_SETS.every((s) => pinnedCourses[s.id])`
|
||||
- [x] 2.2 Switch the visibility filter: `const visible = allPinned ? plans : plans.filter((p) => p.achievedSpecs.length > 0)`
|
||||
- [x] 2.3 Switch the header text: render `"Your Plan"` when `allPinned`, otherwise `"Top Plans"`
|
||||
- [x] 2.4 Hide the "ranked by specs achieved" subtitle when `allPinned`
|
||||
|
||||
## 3. Tests + verification
|
||||
|
||||
- [x] 3.1 Run the existing 97-test suite; confirm all still pass
|
||||
- [ ] 3.2 Browser verify: pin a single course in every set and observe that Top Plans now renders the completed plan with the same achievement count as the spec strip; header reads "Your Plan"
|
||||
- [ ] 3.3 Browser verify the 0-spec edge case: pin courses that yield no achievement; the panel still shows the plan with `0` achievements (no "No plans yet…" placeholder)
|
||||
- [ ] 3.4 Browser verify unpin: unpin one set, header reverts to "Top Plans", search runs normally
|
||||
|
||||
## 4. Version + changelog
|
||||
|
||||
- [x] 4.1 Bump `__APP_VERSION__` and `__APP_VERSION_DATE__` in `app/vite.config.ts` (e.g., `1.5.1`)
|
||||
- [x] 4.2 Add a `CHANGELOG.md` entry: completed plan now surfaces in the Top Plans panel as "Your Plan" once every set is pinned; was previously empty
|
||||
Reference in New Issue
Block a user