12 Commits

Author SHA1 Message Date
Bill 2ebfb9d2ec v1.4.0: Desktop layout redesign + mobile tabs
Specializations move from a 340px left rail to a horizontal 2-row chip
grid at the top (drag L→R to rank). Each chip shows rank, spec-colored
abbreviation tag matching the tags used in plans/schedule, full name on
its own row, status glyph, and a micro credit bar. Hover/tap a chip to
see full status, allocated/threshold credits, and contributing-courses
breakdown in a popover.

The right pane splits into two side-by-side columns on desktop: Top
Plans (left) and Schedule (right), each scrolling independently. The
search progress bar hoists into a global strip below the spec grid so
it stays visible regardless of which column is scrolled.

Schedule blocks render their course choices as a horizontal row of
equal-width buttons (3-5 per set) instead of stacked rows. Pinned sets
collapse to a single line with the course name inline next to the set
title. Term headers (Spring/Summer/Fall) remain as section dividers.

On mobile, the layout becomes a 3-tab segmented control
(Specializations / Plans / Courses) with the search progress strip
above the tabs. The previous floating MobileStatusBanner and
MobileCourseBanner are dropped — tabs replace their navigation
function.
2026-05-09 17:45:28 -04:00
Bill b282709476 v1.3.3: Lex priority comparator + warm-cache cap + score display
The v1.3.1 comparator used a sum-of-weights priorityScore. With weights
15..1 across 15 specs, three lower-priority specs (BNK+BRM+CRF, sum 39)
could outrank a single top-priority spec (HCR alone, sum 15). In
priority-order mode this surfaced lower-priority plans above the user's
top spec — the opposite of intent.

Fix: replace sum-of-weights with a lexicographic rank weight. Each spec
encodes as a bit, top-ranked spec = highest bit. So [HCR] = 16384 beats
[BNK,BRM,CRF,EMT,ENT,FIN,FIM,GLB,LCM,MGT,MKT,MTO,SBI,STR] = 16383. A plan
containing a higher-ranked spec ALWAYS outranks any plan that doesn't,
regardless of how many lower-ranked specs the latter contains. Lower
specs only act as tiebreakers among plans that all contain the same
higher-ranked spec.

Both modes use lex weight as the priority key; modes still differ in
ordering:
  priority-order: (rankWeight desc, count desc, key asc)
  maximize-count: (count desc, rankWeight desc, key asc)

Score display changes from the legacy sum (e.g. "score 29") to the lex
weight in compact form (e.g. "score 24.6k"). Hover for full integer.
The display now actually corresponds to ranking order.

Other:

- Cache cap (500k leaves) now retains existing entries instead of
  clearing on overflow. New entries past the cap are dropped; the
  cached subset stays available as a warm starting point.
- Two new lex-weight tests in searchDecisionTree.test.ts:
  - single top-ranked spec outweighs all 14 others combined
  - tiebreaker is the next-ranked spec
- All 84 tests pass; cached leaves stay valid across the comparator
  change since achievedSpecs (the input to lex compare) is unchanged.

Files: solver/priority.ts (new functions), solver/decisionTree.ts
(comparators take ranking), components/{TopPlans,CourseSelection}.tsx
(score display + Recommended badge), state/appState.ts (cache-cap
behavior), vite.config.ts, CHANGELOG.md.
2026-05-09 16:51:54 -04:00
Bill ee7ea352c4 v1.3.2: Leaf cache for instant pin/unpin + TopPlans block UX
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 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. Cache survives pin,
unpin, and adopt-plan; only ranking or mode changes invalidate it.

Solver / worker:

- searchDecisionTree accepts skipKeys (Set<string>) and pinnedAssignments
  (Record<setId,courseId>). Leaves are emitted with their full 12-set
  assignment so cache keys are stable across pin/unpin operations.
- evaluateLeaf short-circuits when the leaf's assignmentKey is in
  skipKeys: increments iterations + emits progress, but skips the
  optimizer call and all callbacks. Keeps progress percentage honest
  (counts whole tree, not just delta).
- New deriveFromLeaves pure helper produces {topK, setAnalyses} from a
  leaf collection; used by the main-thread cache filter and gives a
  reusable derivation primitive for tests.
- Worker request gains skipKeys and pinnedAssignments fields. Worker
  response gains a leafEvaluated event so the main thread can populate
  its cache as the search streams.

App state:

- leafCacheRef holds Map<assignmentKey, PlanOutcome> scoped to the
  current (ranking, mode) pair. The search effect now: invalidates on
  ranking/mode change; computes the orderedCourses + expectedTotal;
  filters the cache against the current pinned/excluded state; calls
  deriveFromLeaves to render immediately; spawns the worker only when
  filtered.length < expectedTotal, passing skipKeys.
- Cache cap of 500,000 leaves with full clear on overflow. Bounds
  worst-case memory at ~150 MB.

UI (TopPlans):

- Course blocks in the per-plan row are now interactive buttons. Click
  pins (or unpins, if the course is currently pinned) the course in
  that set. Pinned blocks render in a selected blue color.
- Each plan row now shows the FULL 12-set sequence including pinned
  courses (interleaved with the search's recommended choices for the
  remaining open sets) so the displayed plan is always complete.
- Spec qualification tags removed from per-block display (kept the
  set-label + course-name treatment for clarity).

Tests:

- New app/src/solver/__tests__/leafCache.test.ts with 4 tests:
  skipKeys parity (second-pass run with skipKeys evaluates zero
  leaves), deriveFromLeaves parity (matches a fresh search), cache
  filter on pinned assignments, cache filter on excluded courses.
- All 78 prior tests continue to pass; 82 total.

Browser-verified: pin click on a Top Plans block from the cached
8-open-set scenario completes instantly with no spinner; unpin restores
the original cached subset (also instant when the prior space was
already cached); mode toggle correctly invalidates and re-runs the
search.
2026-05-09 16:27:52 -04:00
Bill cb49123930 v1.3.1: Exhaustive decision-tree search + UX refinements
The v1.3.0 saturation termination silently capped the search after only
the heuristic-favored part of the tree, leaving most per-set ceiling cells
stuck at "0 specs" and hiding genuinely-feasible 3-spec plans in
maximize-count mode. Replace with full exhaustive enumeration plus a
batch of UX refinements that emerged during testing.

Algorithm:

- Drop the saturation early-termination entirely. Search now runs the
  full open-set cartesian product to completion; the iteration cap is
  also removed so no scenario exits partial.
- Add mode-dependent DFS child ordering: priority-order keeps the
  priority-target-first heuristic; maximize-count orders children by
  descending count of qualifications for reachable specs (generalist
  courses tried first).
- Make the (count, priorityScore) comparator mode-aware: priority-order
  ranks by (priorityScore, count) so the user's top spec surfaces;
  maximize-count ranks by (count, priorityScore) so the highest count
  wins. The same rule drives both top-K position and per-cell ceiling
  selection (and the Recommended badge).
- Add an evaluated boolean to each ChoiceOutcome and set it on first
  leaf evaluation. Distinguishes "still searching" from "evaluated, no
  specs achieved" so the UI never shows misleading 0 specs for a cell
  the search hasn't reached yet.
- Throttled progress events (~100ms) carrying iterations / total leaf
  count, drive both the per-set spinner and the global progress bar.

UI:

- Top Plans header shows a horizontal progress bar with
  "iterations / total · NN%" while the search runs; collapses to
  "Search complete · N explored" on completion.
- Per-set spinner next to each elective set heading while any choice
  in that set is unevaluated.
- Per-cell pulsing dot + "searching" text for unevaluated cells.
- Replace the "(HCR, BNK, ...)" text labels on each course with
  color-coded SpecTag pills using a new fixed per-spec palette
  (app/src/data/specColors.ts). Same palette applied to the Top Plans
  achievement badges so the two views are visually consistent.
- "Top outcome if picked ↓" caption above the right side of each open
  elective set so the spec tags are clearly identified as decision-tree
  outcomes (not the course's own qualifications).
- Recommended badge moved inline next to the course name (instead of
  on a separate row below) to keep button heights stable.

Tests:

- Replace the saturation early-termination test with an exhaustion test
  asserting every cell ends with evaluated: true and partial: false.
- Add mode-dependent ordering test (max-count visits Climate Finance
  before Corporate Governance in fall3).
- Add evaluated-flag transition test.
- Add throttled progress-event test (>= ~100ms between consecutive
  emits).
- Performance smoke updated to a 60s budget for the exhaustive
  user-scenario search; 8-open-set typical case completes in ~7s.

Files: solver/decisionTree.ts, solver/priority.ts (already shipped),
data/specColors.ts (new), components/{TopPlans,CourseSelection}.tsx,
state/appState.ts, workers/decisionTree.worker.ts,
__tests__/searchDecisionTree.test.ts, vite.config.ts, CHANGELOG.md,
openspec/changes/decision-tree-exhaustive-search/* (full change spec).
2026-05-09 15:47:56 -04:00
Bill 4b80fac500 v1.3.0: Streamed top-K decision-tree plans + priority-aware ceiling
Fixes the bug where a specialization could show "Achievable" while no
per-set ceiling cell surfaces a path to it. Reproduction: pin SP2=Business
of Health & Medical Care, SP4=Foundations of Fintech, SP5=Corporate Finance,
SE1=GIE; rank HCR first. Healthcare showed Achievable but every ceiling
cell excluded HCR.

Root cause: computeCeiling used strict > on count alone, so the first
equal-count combination found won permanently and HCR-including outcomes
were never recorded.

Changes:

- Replace per-(set, choice) computeCeiling loop with a single full-tree
  searchDecisionTree DFS. Both the per-set ceiling table and a new ranked
  top-K plan list (default K=10) are populated from one enumeration.
- Comparison rule everywhere is (count desc, priority score desc,
  deterministic-tiebreak). priorityScore extracted from optimizer.ts
  into a shared priority.ts module used by both call sites.
- Heuristic enumeration ordering: select the first reachable ranked spec
  as priorityTarget; reorder DFS children at every level so target-
  qualifying courses are tried first. High-priority outcomes surface in
  early iterations instead of being blocked by less-relevant equal-count
  results.
- Bounded search: terminate on saturation (top-K stable for 500
  iterations) or hard cap (10000 iterations); set partial=true if cap
  hit. Mitigates the worst-case enumeration cost.
- Worker protocol: tagged-union response with topKUpdate, choiceUpdate
  (per-cell, replaces per-set setComplete), and allComplete events.
- App state adds topPlans/topPlansPartial slices and an adoptPlan action
  that pins a plan's full course assignment in one click. Also fixes
  loadState's stale "ranking.length !== 14" check (now uses
  SPECIALIZATIONS.length so HCR-era saved state restores correctly).
- New TopPlans component renders the ranked list with adopt buttons,
  placed above CourseSelection in the right column.
- 17 new tests in searchDecisionTree.test.ts covering priority scoring,
  bounded ranked list, comparison rule, target selection, the user's
  reproduction scenario, streaming monotonicity, saturation termination,
  and a performance smoke test (< 5s for the 8-open-set case).
- Existing decisionTree.test.ts: one test amended for per-cell streaming
  semantics; remaining 3 unchanged and passing.
2026-05-09 14:51:32 -04:00
Bill 4d6f81d1e5 v1.2.2: Add Healthcare specialization, mark cancelled courses, rename Digital Marketing
Apply the J27 (5/6/2026) Stern specialization sheet:

- Add Healthcare (HCR) as the 15th specialization, with HCR cross-listings on
  spr2-health-medical, spr3-analytics-ml, sum2-social-media (renamed), and
  fall1-managing-change. 10 credits available, no required-course gate.
- Rename sum2-social-media to "Digital Marketing Strategy in Practice";
  replace its description with new MSKCC-anchored content; clear instructor
  pending confirmation of new lead.
- Switch from delete-and-replace to the previously-unused cancelled flag
  (Approach B): mark spr5-customer-insights cancelled, add Managing Growing
  Companies back to Summer Set 2 as a cancelled placeholder per the printed
  sheet.
- Update data integrity tests: course count 46 -> 47, spec count 14 -> 15;
  per-spec "across sets" helper now filters cancelled courses so future
  cancellations trigger an obvious assertion failure (BRM 6 -> 5,
  MKT 7 -> 6, HCR 4 new).
- Replace hardcoded 14 in optimizer.test.ts with SPECIALIZATIONS.length.
2026-05-09 14:50:26 -04:00
Bill 0beafb58b5 Add design docs for achievable status fix and course description popups change artifacts 2026-03-27 19:54:14 -04:00
Bill 8b88402ecd v1.2.1: Fix achievable status accuracy for credit-shared specializations 2026-03-27 19:53:34 -04:00
Bill 578c87d59d fix: mark specs as unreachable when infeasible alongside achieved specs
determineStatuses() was marking specs as 'achievable' based solely on
per-specialization upper bounds, ignoring credit sharing with achieved
specs. Now performs an LP feasibility check to verify the spec can
actually be achieved alongside the current achieved set.
2026-03-27 17:38:27 -04:00
Bill 1907e266c1 Add deployment overrides for inkling (port 8087, reverse proxy network) 2026-03-27 12:32:46 -04:00
Bill 441d61abc3 v1.2.0: Add course info popovers, favicon, and viewport-fitted layout
- Course info popovers with description, instructors, and specialization
  tags; opens on hover (desktop) or tap (mobile) with smart positioning
- Page title and graduation cap favicon in NYU Stern purple
- Desktop layout fits viewport without page-level scrolling
2026-03-27 12:23:15 -04:00
Bill 99a39a2581 v1.1.1: Replace cancelled Managing Growing Companies with Innovation and Design
Replace cancelled course in Summer Elective Set 2 with new course
"Innovation and Design" qualifying for Brand Management, Entrepreneurship
and Innovation, Marketing, and Strategy (S2).
2026-03-27 11:26:53 -04:00
66 changed files with 5185 additions and 311 deletions
+84
View File
@@ -1,5 +1,89 @@
# Changelog
## 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 (35 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
- **Achievable status accuracy** — specializations marked "Achievable" are now verified via LP feasibility check against already-achieved specs; previously, a specialization could show "Achievable" based on raw credit potential while actually being infeasible due to credit sharing with higher-priority achieved specializations
## v1.2.0 — 2026-03-27
### Changes
- **Course info popovers** — each course now has an info icon that opens a popover showing the course description, instructor(s), and specialization tags, extracted from the J27 Electives PDF; opens on hover (desktop) or tap (mobile), with smart positioning that flips above when near the bottom of the viewport
- **Page title and favicon** — updated browser tab from "app" with Vite icon to "EMBA Specialization Solver" with a graduation cap favicon in NYU Stern purple
- **Viewport-fitted layout** — desktop layout now fits within the viewable area without page-level scrolling; each pane scrolls independently
## v1.1.1 — 2026-03-27
### Changes
- **Course replacement** — replaced cancelled "Managing Growing Companies" with new course "Innovation and Design" in Summer Elective Set 2; qualifies for Brand Management, Entrepreneurship and Innovation, Marketing, and Strategy (S2)
## v1.1.0 — 2026-03-13
### Changes
+2 -2
View File
@@ -2,9 +2,9 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>app</title>
<title>EMBA Specialization Solver</title>
</head>
<body>
<div id="root"></div>
+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<polygon points="32,8 2,24 32,40 62,24" fill="#57068c"/>
<polygon points="32,40 2,24 2,28 32,44 62,28 62,24" fill="#3d0066"/>
<rect x="52" y="24" width="2" height="22" fill="#57068c"/>
<circle cx="53" cy="48" r="3" fill="#57068c"/>
<path d="M16,32 v10 c0,6 16,10 16,10 s16,-4 16,-10 v-10 l-16,8z" fill="#7b2fbe"/>
</svg>

After

Width:  |  Height:  |  Size: 392 B

+140 -63
View File
@@ -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,
@@ -25,6 +30,7 @@ function App() {
pinCourse,
unpinCourse,
clearAll,
adoptPlan,
} = useAppState();
const breakpoint = useMediaQuery();
@@ -38,72 +44,126 @@ function App() {
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',
margin: '0 auto',
padding: isMobile ? '12px' : '20px',
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
...(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: 'start' };
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}
onReorder={reorder}
/>
</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>
@@ -117,21 +177,38 @@ function App() {
altModeName={altMode === 'maximize-count' ? 'Maximize Count' : 'Priority Order'}
/>
<div style={panelStyle}>
<div ref={specSectionRef} style={isMobile ? {} : { maxHeight: '85vh', overflowY: 'auto' }}>
<CreditLegend />
<SpecializationRanking
<SpecializationRanking
ranking={state.ranking}
result={optimizationResult}
onReorder={reorder}
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 ? {} : { maxHeight: '85vh', overflowY: 'auto' }}>
<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}
+544 -66
View File
@@ -1,9 +1,33 @@
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 type { Term } from '../data/types';
import { COURSE_DESCRIPTIONS } from '../data/courseDescriptions';
import { courseById } from '../data/lookups';
import { useMediaQuery } from '../hooks/useMediaQuery';
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) {
@@ -12,11 +36,171 @@ for (const spec of SPECIALIZATIONS) {
}
}
// specId → full spec name
const specNameById: Record<string, string> = {};
for (const spec of SPECIALIZATIONS) {
specNameById[spec.id] = spec.name;
}
function CourseInfoPopover({
courseId,
courseName,
anchorRect,
onClose,
onHoverEnter,
onHoverLeave,
}: {
courseId: string;
courseName: string;
anchorRect: DOMRect | null;
onClose: () => void;
onHoverEnter: () => void;
onHoverLeave: () => void;
}) {
const popoverRef = useRef<HTMLDivElement>(null);
const breakpoint = useMediaQuery();
const isMobile = breakpoint === 'mobile';
const info = COURSE_DESCRIPTIONS[courseId];
// Close on Escape
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') onClose();
}
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, [onClose]);
// Close on click outside
useEffect(() => {
function onClickOutside(e: MouseEvent) {
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
onClose();
}
}
// Defer so the opening click doesn't immediately close
const id = setTimeout(() => document.addEventListener('mousedown', onClickOutside), 0);
return () => {
clearTimeout(id);
document.removeEventListener('mousedown', onClickOutside);
};
}, [onClose]);
if (!info) return null;
const instructorLabel = info.instructors.length > 1 ? 'Instructors' : 'Instructor';
const instructorText = info.instructors.length > 0
? info.instructors.join(', ')
: null;
// Mobile: centered fixed overlay. Desktop: anchored near the icon, flipping above if needed.
const popoverMaxHeight = 300;
const spaceBelow = anchorRect ? window.innerHeight - anchorRect.bottom - 6 : popoverMaxHeight;
const spaceAbove = anchorRect ? anchorRect.top - 6 : popoverMaxHeight;
const placeAbove = spaceBelow < Math.min(popoverMaxHeight, 150) && spaceAbove > spaceBelow;
const positionStyle: React.CSSProperties = isMobile
? {
position: 'fixed',
left: '16px',
right: '16px',
top: '50%',
transform: 'translateY(-50%)',
maxWidth: 'calc(100vw - 32px)',
}
: {
position: 'fixed',
left: anchorRect ? Math.min(anchorRect.left, window.innerWidth - 340) : 0,
...(placeAbove
? { bottom: anchorRect ? window.innerHeight - anchorRect.top + 6 : 0, maxHeight: Math.min(popoverMaxHeight, spaceAbove) }
: { top: anchorRect ? anchorRect.bottom + 6 : 0, maxHeight: Math.min(popoverMaxHeight, spaceBelow) }),
maxWidth: '320px',
};
return (
<>
{isMobile && (
<div style={{
position: 'fixed', inset: 0,
background: 'rgba(0,0,0,0.3)',
zIndex: 999,
}} />
)}
<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',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '6px' }}>
<div style={{ fontWeight: 600, fontSize: '14px', color: '#1e293b', flex: 1, paddingRight: '8px' }}>
{courseName}
</div>
<button
onClick={onClose}
style={{
background: 'none', border: 'none', cursor: 'pointer',
fontSize: '16px', color: '#94a3b8', padding: '0 2px',
lineHeight: 1, flexShrink: 0,
}}
aria-label="Close"
>
&times;
</button>
</div>
{instructorText && (
<div style={{ fontSize: '12px', color: '#6366f1', marginBottom: '6px', fontWeight: 500 }}>
{instructorLabel}: {instructorText}
</div>
)}
{courseById[courseId]?.qualifications.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '4px', marginBottom: '8px' }}>
{courseById[courseId].qualifications.map((q) => (
<span
key={q.specId}
title={specNameById[q.specId]}
style={{
fontSize: '10px', fontWeight: 600,
padding: '2px 6px', borderRadius: '3px',
background: '#f0f0ff', color: '#4338ca',
border: '1px solid #e0e0ff',
whiteSpace: 'nowrap',
}}
>
{q.specId}{q.marker !== 'standard' ? ` (${q.marker})` : ''}
</span>
))}
</div>
)}
<div style={{
fontSize: '12px', color: '#475569', lineHeight: 1.5,
overflowY: 'auto', flex: 1,
whiteSpace: 'pre-line',
}}>
{info.description}
</div>
</div>
</>
);
}
interface CourseSelectionProps {
pinnedCourses: Record<string, string | null>;
treeResults: SetAnalysis[];
treeLoading: boolean;
disabledCourseIds: Set<string>;
ranking: string[];
mode: OptimizationMode;
onPin: (setId: string, courseId: string) => void;
onUnpin: (setId: string) => void;
onClearAll: () => void;
@@ -29,8 +213,17 @@ function ElectiveSet({
analysis,
loading,
disabledCourseIds,
scorer,
rankWeight,
mode,
isMobile,
onPin,
onUnpin,
openPopoverId,
onOpenPopover,
onClosePopover,
onHoverOpen,
onHoverLeave,
}: {
setId: string;
setName: string;
@@ -38,8 +231,17 @@ 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;
onOpenPopover: (courseId: string, rect: DOMRect) => void;
onClosePopover: () => void;
onHoverOpen: (courseId: string, rect: DOMRect) => void;
onHoverLeave: () => void;
}) {
const courses = coursesBySet[setId];
const isPinned = pinnedCourseId != null;
@@ -51,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}
@@ -77,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',
@@ -102,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);
@@ -110,69 +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',
}}>
{course.name}
{isCancelled && (
<span style={{ fontSize: '11px', color: '#999', marginLeft: '6px', fontStyle: 'normal', textDecoration: 'none' }}>
(Cancelled)
</span>
)}
{!isCancelled && isDisabled && (
<span style={{ fontSize: '11px', color: '#999', marginLeft: '6px' }}>
(Already selected)
</span>
)}
</span>
{!isUnavailable && showSkeleton ? (
<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>
@@ -184,15 +597,61 @@ 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]));
// Single popover state: only one open at a time
const [popover, setPopover] = useState<{ courseId: string; courseName: 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 handleOpenPopover = useCallback((courseId: string, rect: DOMRect) => {
cancelHoverClose();
const allCourses = Object.values(coursesBySet).flat();
const course = allCourses.find(c => c.id === courseId);
setPopover({ courseId, courseName: course?.name ?? '', anchorRect: rect });
}, [cancelHoverClose]);
const handleClosePopover = useCallback(() => {
cancelHoverClose();
setPopover(null);
}, [cancelHoverClose]);
// Hover open: same as click open but cancels any pending close
const handleHoverOpen = useCallback((courseId: string, rect: DOMRect) => {
cancelHoverClose();
const allCourses = Object.values(coursesBySet).flat();
const course = allCourses.find(c => c.id === courseId);
setPopover({ courseId, courseName: course?.name ?? '', anchorRect: rect });
}, [cancelHoverClose]);
// Hover leave: delayed close so mouse can move from icon to popover
const handleHoverLeave = useCallback(() => {
hoverCloseTimer.current = setTimeout(() => {
setPopover(null);
}, 150);
}, []);
return (
<div>
<style>{skeletonStyle}</style>
@@ -228,12 +687,31 @@ 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}
onOpenPopover={handleOpenPopover}
onClosePopover={handleClosePopover}
onHoverOpen={handleHoverOpen}
onHoverLeave={handleHoverLeave}
/>
))}
</div>
))}
{popover && (
<CourseInfoPopover
courseId={popover.courseId}
courseName={popover.courseName}
anchorRect={popover.anchorRect}
onClose={handleClosePopover}
onHoverEnter={cancelHoverClose}
onHoverLeave={handleHoverLeave}
/>
)}
</div>
);
}
@@ -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>
);
}
+381 -2
View File
@@ -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 }> = {
@@ -217,10 +220,14 @@ function SortableItem({ id, rank, total, name, status, allocated, potential, isE
interface SpecializationRankingProps {
ranking: string[];
result: AllocationResult;
headerSlot?: React.ReactNode;
onReorder: (ranking: string[]) => void;
}
export function SpecializationRanking({ ranking, result, onReorder }: SpecializationRankingProps) {
export function SpecializationRanking({ ranking, result, headerSlot, onReorder }: 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 +278,24 @@ 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}
sensors={sensors}
onDragEnd={handleDragEnd}
getAllocatedCredits={getAllocatedCredits}
specMap={specMap}
achievedSummary={achievedSummary}
headerSlot={headerSlot}
/>
);
}
return (
<div>
@@ -305,3 +330,357 @@ export function SpecializationRanking({ ranking, result, onReorder }: Specializa
</div>
);
}
// ===== Desktop strip variant =====
interface DesktopSpecStripProps {
ranking: string[];
result: AllocationResult;
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;
}
function DesktopSpecStrip({ ranking, result, sensors, onDragEnd, getAllocatedCredits, specMap, achievedSummary, headerSlot }: 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}
isOpen={popover?.specId === id}
onHoverOpen={openPopover}
onHoverLeave={handleHoverLeave}
onTapToggle={(specId, rect) => {
if (popover?.specId === specId) closePopover();
else openPopover(specId, rect);
}}
/>
))}
</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)}
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;
isOpen: boolean;
onHoverOpen: (specId: string, rect: DOMRect) => void;
onHoverLeave: () => void;
onTapToggle: (specId: string, rect: DOMRect) => void;
}
function SpecChip({ id, rank, name, abbreviation, status, allocated, potential, isOpen, onHoverOpen, onHoverLeave, onTapToggle }: 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, threshold);
const allocPct = Math.min((allocated / denom) * 100, 100);
const potentialPct = Math.min((potential / denom) * 100, 100);
const thresholdPct = Math.min((threshold / denom) * 100, 100);
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={{ color: style.color, fontWeight: 700, marginLeft: 'auto' }} aria-label={style.label}>
{statusGlyph}
</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: 0, top: 0, height: '100%',
width: `${potentialPct}%`, background: '#bfdbfe',
transition: 'width 300ms ease-out',
}} />
)}
<div style={{
position: 'absolute', left: 0, top: 0, height: '100%',
width: `${allocPct}%`,
background: allocated >= threshold ? '#22c55e' : '#3b82f6',
transition: 'width 300ms ease-out',
}} />
<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;
allocations: Record<string, Record<string, number>>;
anchorRect: DOMRect;
onClose: () => void;
onHoverEnter: () => void;
onHoverLeave: () => void;
}
function SpecChipPopover({ specId, name, status, allocated, 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 > 0 ? allocated.toFixed(1) : '0'} / 9.0 credits
</span>
</div>
{contributions.length > 0 && (
<div style={{ marginTop: '4px', borderTop: '1px solid #f1f5f9', paddingTop: '6px' }}>
<div style={{ fontSize: '11px', color: '#94a3b8', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.4px', marginBottom: '4px' }}>
Contributing courses
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '2px' }}>
{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>
);
}
+282
View File
@@ -0,0 +1,282 @@
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 visible = plans.filter((p) => p.achievedSpecs.length > 0);
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' }}>
Top Plans
{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>
);
}
+15 -12
View File
@@ -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', () => {
@@ -50,7 +50,7 @@ describe('Data integrity', () => {
});
});
it('has exactly 10 S1 markers and 7 S2 markers for Strategy', () => {
it('has exactly 9 S1 markers and 8 S2 markers for Strategy', () => {
let s1Count = 0;
let s2Count = 0;
for (const course of COURSES) {
@@ -59,8 +59,8 @@ describe('Data integrity', () => {
if (q.specId === 'STR' && q.marker === 'S2') s2Count++;
}
}
expect(s1Count).toBe(10);
expect(s2Count).toBe(7);
expect(s1Count).toBe(9);
expect(s2Count).toBe(8);
});
it('all qualification markers are valid types', () => {
@@ -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);
});
+218
View File
@@ -0,0 +1,218 @@
/**
* Course descriptions and instructors extracted from the J27 Electives-Course Descriptions PDF
* and supplementary sources. Keyed by course ID to handle per-set instructor differences.
*/
export interface CourseInfo {
description: string;
instructors: string[];
}
export const COURSE_DESCRIPTIONS: Record<string, CourseInfo> = {
// === Spring Elective Set 1 ===
'spr1-global-immersion': {
description: 'As part of the EMBA Program, students may participate in an optional second-year Global Immersion Experience, in addition to the required first-year GIE. Like the required GIE, this course enables students to gain an understanding of doing business in a given global region through exposure to business leaders and local organizations and institutions. Students travel internationally to a unique global market. In groups, they study the local market and produce a final business idea proposal based on learnings garnered during the GIE.',
instructors: [],
},
'spr1-collaboration': {
description: 'Negotiation is the process by which people work together to achieve mutually agreeable outcomes and/or resolve differences. This course provides students with frameworks and concepts for understanding the negotiation process and the opportunity to improve their negotiation and conflict resolution skills. The learning method is experiential. Students engage in a series of negotiation simulations designed to give them experience with a range of different types of negotiations. Emphasis is on principles, strategies and tactics for reaching effective outcomes.',
instructors: ['Steve Blader'],
},
'spr1-high-stakes': {
description: 'As a business leader you are expected to give direction, establish vision and manage career events with well-honed, clear messages and constant communication clarity. Achieving and maintaining this level of excellence is easier said than done. Conquering High Stakes Communications focuses on the tactics and skills you need not only to manage critical conversations, but also to identify them in advance with the objective of maneuvering the ultimate outcome to your advantage. This course quickly reinforces core communication skills and teaches advance communication techniques through intensive drills and immersive scenario-based role-playing sessions. It also personalizes the experience by having students bring "real life" managerial and communication challenges they may currently be facing to the course for assessment and strategy setting. This approach allows students to take what they are learning and immediately put these skills, strategies, and techniques into action at work and outside the classroom.',
instructors: ['Steve Mellas', 'Jim Donofrio'],
},
// === Spring Elective Set 2 ===
'spr2-consumer-behavior': {
description: 'Each of us are active consumers in everyday life, purchasing everything from groceries to clothing to college educations. However, our individual personalities and characteristics dictate that no two consumers are alike; we make unique choices, making it challenging for marketers to create coherent marketing strategies regardless of similarities within target markets. In this class, we will examine how and why consumers behave the way they do, how environmental impacts shape how we behave, and the practical marketing implications of that behavior.',
instructors: ['Radhika Duggal'],
},
'spr2-health-medical': {
description: 'This course is designed to give the student a general understanding of applied economics of healthcare. In particular, it provides an advanced critical analysis of the delivery of healthcare services and how it is constantly changing. It evaluates the responses of major players including hospitals, physicians, payers, life sciences and new entrants to the market. As expectations for a unified, efficient, cost effective, and high quality global system continue to be desired, the entire ecosystem is trying to adapt. At the end of the course the student will understand why economics in Healthcare is constantly changing and the major drivers impacting the system. Healthcare is the perfect industry to see how market failures occur and why innovation, government regulation, and other interventions may be necessary. While this course is an economics course, it is focused on the application of principles in real life settings and uses current events to highlight their impact. We also try to touch upon regulatory reform and understand how each constituent has an impact on the others within the system. In order to bring in other perspectives, c-suite level executives are brought in as guests.',
instructors: ['Clifford Bleustein'],
},
'spr2-human-rights': {
description: 'This course will explore some of the choices global businesses face in addressing human rights challenges in their core business operations. The course will include a series of case studies from different industries where accelerating globalization and advancing technology have made these challenges more acute in recent years. The course will focus on the evolving public face of these issues driven in part by rapid developments in the global media and advances in information technology.',
instructors: ['Michael Posner'],
},
'spr2-financial-services': {
description: 'The financial services industry touches all of our lives and has been going through a continuous transformation since the deregulation that began in the early 1970\'s. That evolution has accelerated in recent years as more and more pressure has been brought to bear by various stakeholders in the industry who have divergent goals and agendas. Those invested stakeholders include clients, investors, employees, regulators/politicians and the public at large. Overlaying all of this change has been rapid technological advancement that has had a direct impact on how the industry delivers its services, meets expected equity returns and manages the risk inherent in that delivery. This course is an Advanced Finance Elective where THINKING not MEMORIZING is what it is all about.\n\nThis course is a survey course. It provides a broad overview of the financial services industry and of the forces that are continuing to change it worldwide. That change/evolution has resulted in a confederation of sometimes integrated products and services within a multi-product firm. It has also resulted in individual stand-alone businesses within those same integrated financial firms, or in boutique, stand-alone, limited product firms. The course focuses on four big questions: (1) Why and what kind of services are provided by participants in the industry? (2) Who develops, provides and regulates those services? (3) How are they likely to be executed or modified in the future? (4) What skills (both technical and "soft") are required for an individual to succeed in the industry?',
instructors: ['James Finch'],
},
// === Spring Elective Set 3 ===
'spr3-mergers-acquisitions': {
description: 'This course is designed to take an interdisciplinary approach to understanding the problems of formulating and implementing successful acquisition strategies. Our major objectives are (1) to enable you to act as a senior advisor to your CEO regarding strategic M&A and shareholder value issues your division or company might confront and (2) to assist you in becoming an informed consumer of just about anything written on M&A success (including pitches by professional services providers).\n\nWe will introduce a framework for thinking about acquisitions as a strategic investment where the bottom line is superior shareholder performance. The course will approach acquisitions as a multi-step strategic and organizational process drawing from the fields of strategy, negotiations, finance and organizational behavior.',
instructors: ['Mark Sirower'],
},
'spr3-digital-strategy': {
description: 'Digital technologies are playing increasingly important roles in today\'s businesses, markets, economies, and societies worldwide. As digital technologies transform or disrupt organizations\' internal operations and external industry ecosystems, business professionals need to acquire an understanding of the dynamic relationship between technologies and strategies. Such knowledge is critical to effectively leading, participating in, or creating any organization in both private and public sectors. This course is designed to provide a strategic perspective on digital transformation and disruption, and help students develop the knowledge for analyzing, managing, and leveraging digital technologies in diverse settings.',
instructors: ['Ning Su'],
},
'spr3-managing-high-tech': {
description: 'We live in an era where "technology" companies are fundamentally changing our lives, and artificial intelligence will further propel this trend. Understanding this mass transformation is crucial, as it\'s evident that each and every industry is being disrupted by technology. Students will study how "management" is conducted in high-tech companies and grasp the distinctions between managing a high-tech company and a traditional one. This course will encompass the megatrends in the technology sector and various real-world business cases. Topics to be covered in this course include (1) Critical success factors of big tech companies; (2) Platform businesses (two-sided business, content platform business); (3) Founding a startup and entrepreneurship (4) Managing innovation; (5) Tech M&As; (6) Culture & People management in the tech industry, and more.\n\nIn addition to U.S. tech companies, we will also discuss Asian companies renowned for their advanced technology applications, such as Baidu, Tencent, Alibaba, and Pinduoduo in China, as well as Kakao and Naver in South Korea. The Professor will also share his experience working as the CEO at Kakao Corp. to help students understand the "CEO Perspective."',
instructors: ['Jihoon Rim'],
},
'spr3-analytics-ml': {
description: 'There are a variety of statistical methods, old and new, that are used nowadays to analyze datasets, which can range in size from the small to the enormous. This course gives an introduction to, an overview of and a comparison between these various methods and the attendant terminology and the different kinds of questions they can help answer. Methods that will be discussed include Regression, the Lasso, Discriminant Analysis, Logistic regression, Regression and Classification Trees, etc. This course is not meant for the people doing the analysis but will be geared more towards helping managers understand the material and aid them in having meaningful conversations with the analytics groups in their firms. However, due to its very nature, there will be a mathematical aspect to this course (i.e. there will be formulae), though the aim is to try to understand the intuition behind them.',
instructors: ['Rohit Deo'],
},
// === Spring Elective Set 4 ===
'spr4-fintech': {
description: '"Fintech" is the label for increasingly technological approaches to the main financial intermediation functions: payments, capital raising, remittances, managing uncertainty and risk, market price discovery, and mediating information asymmetry and incentives. In today\'s FinTech businesses, consumers bank via mobile apps integrated into social media, institutions trade electronically, and robo-advisers make decisions about investment portfolios. This inter-departmental course provides an introduction to the emerging FinTech discipline. It is a good starting point for Stern students who may take additional electives in the FinTech area, while also providing an overview of the area for students who intend to take only one FinTech course.',
instructors: ['Kathleen DeRose'],
},
'spr4-sustainability': {
description: 'In this course, students will develop an understanding of how leading companies in many sectors are embedding sustainability in their core business strategy and using it to drive innovation; operational efficiency; employee, supplier and customer loyalty; competitive advantage and value to society. They will 1) become familiar with the key environmental and social issues affecting business today, 2) explore the innovations developed by corporate leaders in pursuit of sustainability, 3) become familiar with the latest consumer insight research on sustainability and 4) begin to develop some of the skills required for leading in this new social and political environment (e.g. multi-stakeholder management). This course is multi-disciplinary, and seeks to integrate across the functions of the firm to arrive at an effective firm-wide leadership sensibility.',
instructors: ['Alison Taylor'],
},
'spr4-pricing': {
description: 'Price setting is probably the most critical of all marketing mix decisions. It involves an understanding of both supply side factors (e.g. costs) and demand side factors (e.g. consumer willingness to pay). While traditional approaches to pricing theory have revolved around an economic and financial framework, a broader and more pragmatic view entails a comprehensive understanding of the demand side; both at the level of individual customer values, and the more aggregate level of price sensitivities of the market. In this course, we will approach the pricing decision as an intersection of economic, strategic, and behavioral considerations. Using product categories as diverse as healthcare, entertainment, cell phone plans, electronic road pricing, consumer packaged goods, cloud services and durable goods, we will study economic and behavioral approaches to pricing, value-based pricing, price discrimination/customization, bundling, versioning, add-on pricing, subscription pricing, dynamic pricing, durable goods pricing, yield management, freemium pricing, and pricing in two-sided markets.',
instructors: ['Kazu Ishihara'],
},
'spr4-foundations-entrepreneurship': {
description: 'This course seeks to explore the many dimensions of new venture creation and growth and to foster innovation and new business formation in independent and corporate settings. The course will integrate both an academic and practitioner view of the challenges facing entrepreneurs and investors involved in entrepreneurial, venture capital and private equity investment activities. The course draws on a variety of disciplines, including management and finance, to develop frameworks and techniques that are needed to plan, start, evaluate and successfully operate ventures.',
instructors: ['Glenn Okun'],
},
// === Spring Elective Set 5 ===
'spr5-corporate-finance': {
description: 'This course helps students develop an analytical framework for understanding how organizations make investment and financing decisions. Students also learn the theory and practice of various valuation techniques. There is an emphasis on understanding the theory and its applications to the real world as well as appreciating the limitations of the tools in practical settings. Specific topics include capital budgeting investment decision rules discounted cash flow valuation real options cost of capital structure dividend policy and valuation methods such as WACC and APV.',
instructors: ['Anjolein Schmeits'],
},
'spr5-consulting-practice': {
description: 'The Stern Consulting Corps (SCC): Consulting Practice— Process and Problem Solving is a hands-on experiential learning opportunity that allows students to work in teams to tackle a business issue or opportunity for a client while applying in real time the key steps of the consulting process they are learning in the classroom. Students will discuss their challenges, approaches and recommendations in class and on-line while benefiting from the guidance and expertise of the instructor and a seasoned strategy consulting professional. Whether students are going into the consulting field or another area of business, this course will show you how to break a complicated problem into pieces that can be individually and methodically addressed. We will discuss how to gather the right data to build a relevant fact base which can be used to drive key conclusions. By working on a live SCC project concurrently, you will benefit from the \'flipped classroom model\' to seamlessly integrate knowledge with practice and leave the experience confident in problem solving abilities.',
instructors: ['Keshava Dasarathy'],
},
'spr5-global-strategy': {
description: 'This course provides an understanding of the cultural, political, competitive, technological, legal, and ethical environment in which multinational firms operate. It surveys a range of tools and techniques of environmental analysis for use in assessing foreign and global conditions, opportunities, and threats. It also focuses on multinational corporate strategy, organization, and management. Students examine the building of strategic capabilities, collaborating across boundaries, developing coordination and control, and managing activities and tasks, as well as challenges of worldwide functional management, geographic subsidiary management, and top-level headquarters management.',
instructors: ['Sinziana Dorobantu'],
},
'spr5-customer-insights': {
description: 'Great insights start with sound research. This course explores the research methods used to understand consumers and their behavior. A solid understanding of these methods enables a critical assessment of the accuracy, relevance, and potential biases in consumer data, leading to more informed strategic decisions. As students collect consumer data firsthand, they develop a deeper appreciation for the theory, rationale, and psychometric principles underlying behavioral science research. This knowledge helps to ensure that insights generated from research are meaningful and align with strategic business goals.',
instructors: ['Carol Pluzinski'],
},
// === Summer Elective Set 1 ===
'sum1-global-immersion': {
description: 'As part of the EMBA Program, students may participate in an optional second-year Global Immersion Experience, in addition to the required first-year GIE. Like the required GIE, this course enables students to gain an understanding of doing business in a given global region through exposure to business leaders and local organizations and institutions. Students travel internationally to a unique global market. In groups, they study the local market and produce a final business idea proposal based on learnings garnered during the GIE.',
instructors: [],
},
'sum1-collaboration': {
description: 'Negotiation is the process by which people work together to achieve mutually agreeable outcomes and/or resolve differences. This course provides students with frameworks and concepts for understanding the negotiation process and the opportunity to improve their negotiation and conflict resolution skills. The learning method is experiential. Students engage in a series of negotiation simulations designed to give them experience with a range of different types of negotiations. Emphasis is on principles, strategies and tactics for reaching effective outcomes.',
instructors: ['Elizabeth Morrison'],
},
'sum1-high-stakes': {
description: 'As a business leader you are expected to give direction, establish vision and manage career events with well-honed, clear messages and constant communication clarity. Achieving and maintaining this level of excellence is easier said than done. Conquering High Stakes Communications focuses on the tactics and skills you need not only to manage critical conversations, but also to identify them in advance with the objective of maneuvering the ultimate outcome to your advantage. This course quickly reinforces core communication skills and teaches advance communication techniques through intensive drills and immersive scenario-based role-playing sessions. It also personalizes the experience by having students bring "real life" managerial and communication challenges they may currently be facing to the course for assessment and strategy setting. This approach allows students to take what they are learning and immediately put these skills, strategies, and techniques into action at work and outside the classroom.',
instructors: ['Steve Mellas', 'Jim Donofrio'],
},
// === Summer Elective Set 2 ===
'sum2-innovation-design': {
description: 'The pace of disruption today is unlike anything business has faced before. Generative AI is rewriting entire industries in months. Climate imperatives are forcing fundamental redesigns of supply chains, energy systems, and product architectures. Geopolitical fragmentation is redrawing the map of global commerce. In this environment, the companies that hesitate don\'t just fall behind—they become irrelevant.\n\nMost organizations treat disruption as something that happens to them. The best disruptors treat it as something they build—architecting bold moves from a position of strength, before the market forces their hand. Yet most leaders default to what\'s familiar, optimizing yesterday\'s business model rather than designing tomorrow\'s. Successful companies are especially vulnerable: past performance becomes a cognitive trap, making incremental change feel safer than the bold moves the moment demands.\n\nBuilt for senior executives who must drive transformation—not just manage it—Innovation and Design equips you with the frameworks, mindset, and practical tools to identify non-obvious opportunities, challenge entrenched assumptions, and execute disruptive solutions with speed and conviction.',
instructors: ['Luke Williams'],
},
'sum2-social-media': {
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.',
instructors: ['Anat Lechner'],
},
'sum2-business-drivers': {
description: 'This course covers business drivers of a wide range of industries. Having a perspective about how various industries make money is critical whether you analyze a company for investment, advise its managers, manage its operations, market its products, or choose its capital structure. The course will involve case presentations of various industries and will require active class participation. The course will provide a framework to analyze financial and strategic performance and to identify business drivers.',
instructors: ['Dan Gode'],
},
// === Summer Elective Set 3 ===
'sum3-valuation': {
description: 'This course covers a broad range of issues in corporate financial management. We analyze the core financial decisions made by firms, the investment decision and the financing decision, and examine their impact on the value of the firm in the financial market. Topics that will be covered are: financial planning and forecasting, project analysis and evaluation, resource allocation within firms, valuing flexibility in investment projects, capital structure policy and cost of capital, payout policy, corporate restructurings and firm valuation. A large emphasis will be placed on the application of the concepts and tools developed in the course to financial decisions made by firms through case analysis and real-world examples. By the end of the course, participants should feel comfortable performing a sound analysis of a variety of corporate decisions, and should have developed a thorough understanding of how analyzing strategic and financial decisions from the perspective of value creation can improve managerial decision-making.',
instructors: ['Anjolein Schmeits'],
},
'sum3-entertainment-media': {
description: 'This course provides a framework for understanding the key marketing, economic, and strategic issues facing organizations in the entertainment industry. Covers key sectors of the entertainment industry focusing on film, television, home video, cable, music, publishing, sports, and new media. The course utilizes lectures and case studies.',
instructors: ['Paul Hardart'],
},
'sum3-advanced-corporate-strategy': {
description: 'This course will tackle advanced corporate strategy, and in particular the execution of corporation strategy (e.g., negotiating prices in acquisitions, integration, etc.). This course will pick up where Strategy 2 leaves off and prepare students for the complex task of actually implementing corporate strategy moves such as mergers, acquisitions, spinoffs, etc.',
instructors: ['Michael Dumais'],
},
'sum3-power-influence': {
description: 'This course is designed for individuals interested in learning more about the art and science of influence in organizations. Many people are ambivalent, if not disdainful, of those who seek to wield power and influence at work, but power and influence are key mechanisms by which things get done. For those considering careers in management, it is important to be able to diagnose situations as opportunities to exercise power and influence in order to form and implement new strategies. In addition, managers are usually on the receiving end of these processes. An astute manager knows how to anticipate moves that others will make, how to block or avoid them when they have undesirable consequences, and how to help these moves succeed when their consequences are beneficial.\n\nThe course aims to provide you with "political intelligence" in a sense. After taking this course, you will be able to: (1) diagnose the true distribution of power in organizations, (2) understand your own relationship to power, (3) build your own professional sources of power, and (4) develop influence techniques so that you can resolve conflict more effectively, foster cooperation, and lead change in organizations. These skills will be invaluable throughout your career.',
instructors: ['Molly Kern'],
},
// === Fall Elective Set 1 ===
'fall1-operations-strategy': {
description: 'Operations is concerned with the systematic design, management, and improvement of the processes that transform inputs into finished goods or services. It is one of the primary functions of a firm. As marketing induces demand for products and finance provides the capital, operations produce the product (goods and services).\n\nThis course is intended to provide students a better understanding of how firms can gain competitive advantage from their operations function. Typically, this requires the firm to achieve, at a minimum cost, quality and ecological parity; responsiveness and adaptability to customer needs and desires; rapid time to market; process technology leadership; and sufficient and responsive capacity. The course is designed to develop a problem-solving framework that enables students to undertake managerial and technical analysis of operations that should result in the desired comparative advantage. Unlike many courses, which tend to treat the firm as a "black box", we will be primarily concerned with "opening up" the black box and discovering what makes a firm "tick" - or, for that matter, "stop ticking". Because the operations of a firm vary widely from one industry to the next, a course like this cannot cover all topics that are relevant to any given industry. Rather, I have selected a set of topics that are fundamental to understanding operations in a wide range of industries. These concepts are then illustrated using cases from a diverse set of businesses.',
instructors: ['Srikanth Jagabathula'],
},
'fall1-private-equity': {
description: 'This course examines the private equity marketplace. Private equity is a significant source of capital for both new ventures and established firms. Private equity is the investment of capital in private companies to fund growth or in public companies to take them private. Private equity is segregated into several segments of which the principal focus of this class is on the leveraged buyout markets.\n\nThe objective of this course is to provide an overview of the private equity market from the differing perspectives of private equity investors (limited partners), private equity fund sponsors (general partners) and the managers of portfolio companies by focusing on the nature of the market and the strategies employed. The "private equity cycle" will be explored and developed in the course. The private equity cycle includes: (1) Private equity fundraising and structure; (2) Investment origination, valuation, value creation and investment management; (3) Exit strategies.',
instructors: ['Bob Semmens'],
},
'fall1-managing-change': {
description: 'Contemporary business environments contain challenges that demand an increasing pace, volume and complexity of organizational change. Most organizations, whether they are entrepreneurial start-ups or long-established Fortune 500 firms, find that they must change or wither. This course is geared toward deepening students\' understanding of the challenges, the techniques, and the burdens associated with initiating and implementing major change in an organization. The course is especially useful for students who plan careers in management consulting, general management (whether in line or staff positions) and entrepreneurship.\n\nThe perspective on change adopted in this course is that competitive advantage today is less a matter of determining the right strategy than of implementing it faster and more smoothly than your competitors. As a result, this course concentrates on process, or how change can be most effectively implemented. In keeping with the emphasis on change process, the course focuses on the exploration and classroom discussion of cases illustrating different change efforts in a variety of organizations across a diverse range of business contexts.',
instructors: ['Lynn Gonsor'],
},
'fall1-social-entrepreneurship': {
description: 'The world today is facing multiple intersecting crises—environmental devastation, unprecedented economic inequality, deteriorating health outcomes, racial injustice, amongst others. Social entrepreneurship is the process by which effective, innovative and sustainable business solutions are pioneered to meet these and other social and environmental challenges. It aims to design businesses and leverage the tools of business to create meaningful and measurable impact in the world.\n\nThis course is dedicated to exploring the depths of this rapidly evolving domain. It will explore definition, offer context into how it fits within broader economics and management frameworks, introduce high impact case examples and speakers, offer tools to identify social sector problems and create solutions, explore business designs, and examine methods to measure impact.',
instructors: ['Hans Taparia'],
},
// === Fall Elective Set 2 ===
'fall2-real-estate': {
description: 'This accelerated course focuses on institutional real estate investment, in the United States and globally. It covers real estate property level valuation and risk analysis in the context of a series of case studies. These projects allow graduated skill development over the span of the course, an opportunity to apply financial and property market analysis tools, hone investment presentation skills, and develop skills and techniques useful to effective investment committee decision-makers.\n\nDepending on the level of prior students\' experience, individual members of the presenting "deal team" groups may focus on topics ranging from core market supply/demand forecasting, return sensitivity analysis, debt/equity optimization, and/or sponsor/investor deal structuring features.\n\nWhile primarily relevant to students with an interest in real estate equity and debt investment, the skills and concepts covered in this class are relevant across investment sectors. Prior estate investment experience is not required.',
instructors: ['Sam Chandan'],
},
'fall2-decision-models': {
description: 'One of the most crucial skills for a modern manager is knowing how to use data to make decisions. In Decision Models & Analytics, you will learn how to apply modern analytical approaches—including optimization and simulation—to solve complex business problems. Regardless of your career path, the ability to frame, model, and analyze trade-offs in decision-making will make you a more effective decision maker and give you a significant edge.\n\nThis is a hands-on, lab-style course that emphasizes practical modeling. You will work extensively in Excel to build and analyze decision models, learning how to frame and structure complex business problems. The course also explores how Generative AI can assist in constructing and adapting these models—enabling faster model development, easier exploration of alternative approaches, and greater adaptability beyond traditional tools.\n\nThe course is designed to be relevant across a wide range of industries and functional areas, including tech, consulting, finance, government, human resources, operations, and marketing.',
instructors: ['Jiawei Zhang'],
},
'fall2-behavioral-finance': {
description: 'What moves market prices for instruments like stocks? Is it fundamentals like earnings, growth, discount rates etc. or something else like psychology & frictions or a combination of both.\n\nFinance theory has long relied on a descriptively sparse model of behavior based on the premise that investors and managers are rational at a collective level and that arbitrage frictions are minimal. In recent years both assumptions have been questioned as the standard model, called the Efficient Market Hypothesis (EMH), fails to account for various aspects of actual fluctuations that appear not to be connected to fundamentals.\n\nBehavioral finance (BF) allows for the condition that investors and managers are not always rational and may make systematic errors of judgment that affect market prices. At the extreme these errors are bubbles or crashes.\n\nWe begin by identifying the respective assumptions of each finance model - EMH and BF - as it relates to three instrument types to varying levels: stocks (65%), cryptocurrencies (fungible coins, 25%) and non-fungible tokens (NFTs, 10%). We then explore practical, real-world examples of these two model assumptions in liquid markets by examining various investing biases and market frictions.',
instructors: ['Ian D\'Souza'],
},
'fall2-crisis-management': {
description: 'Effective crisis management is a competitive advantage and a critical attribute of leadership. It isn\'t the nature of the underlying crisis that determines whether a company emerges with its operations, reputation, and financial condition intact, but rather the nature of the response.\n\nThis course focuses on the business decisions, management processes, and leadership skills necessary to anticipate, plan for, manage through, communicate about, and recover from crises affecting corporations and other complex organizations. A key focus of the course is organizational behavior, especially the ways companies in distress and the stakeholders who matter to those companies predictably behave when things go wrong. Another important focus is leadership: how those who lead organizations can maintain the confidence and trust of internal and external stakeholders. The third is strategy: how to navigate a crisis in such a way as to protect long-term business interests and deliver on critical business strategies.\n\nThe course examines examples of effective and ineffective crisis management and topics covered include: case studies in effective and ineffective response, obtaining public forgiveness, thinking strategically in a crisis, crisis communication basics, mistake chains and pattern recognition, taking risks seriously, and neuroscience and crisis.',
instructors: ['Helio Fred Garcia'],
},
// === Fall Elective Set 3 ===
'fall3-corporate-governance': {
description: 'Corporate Governance has evolved into one of the more compelling and challenging subjects in business, law and society. Governance is a multi-faceted and dynamic topic that has socioeconomic, ethical, legal, and regulatory dimensions. The purview of this course is broad, and its intent is to integrate learning from your core EMBA coursework and work experiences to develop interdisciplinary skills to address critical governance issues. These include subjects such as framing the corporate strategy and investment thesis, management leadership and succession planning, undertaking the decision to pursue an initial public offering, pursue a substantial corporate restructuring or engage in a significant acquisition, a business unit sale, or a total entity sale.\n\nWe will also discuss some core business principles—including how to think about capital allocation and understanding key principles of valuation. We will examine the roles and responsibilities (in business theory and in practice) of senior management, directors, shareholders and the corporation\'s other stakeholders—employees, customers, suppliers, and society writ small (the local communities that are touched by the corporation) and writ large (the larger constituent groups—domestic and international)—affected by the corporations\' environmental, social and governance policies, successes and failures.',
instructors: ['Sam Liss'],
},
'fall3-climate-finance': {
description: 'Climate change presents one of the central challenges of our generation, with a wide range of effects on financial markets and the broader economy. At the same time, financial markets play an important role in financing the transition to a net-zero economy. In this class, we study the interaction between climate change and firms, financial markets, energy markets, regulators, and policy makers.\n\nGiven that climate change and sustainability issues more generally are affecting nearly every aspect of the corporate, regulatory, and non-profit worlds, the class will be valuable for students with a wide range of backgrounds and career goals, whether they are directly interested in climate and sustainability issues or primarily want to gain a better understanding of how these issues influence more traditional roles in the corporate and financial sector.\n\nThe class is very applied in its outlook but we will frame the analysis through the lens of economic frameworks that help students think through the interactions between climate change and the broader economy in a systematic way. The objective is that the broad selection of topics and guest speakers will provide a variety of complementary perspectives on how climate change will shape economics and finance over the coming decades.',
instructors: ['Johannes Stroebel'],
},
'fall3-emerging-tech': {
description: 'This course provides a thorough examination of several key technologies that enable major advances in e-business and other high-tech industries, and explores the new business opportunities that these technologies create. For each of these technologies, it provides an overview of the space corresponding to this class, examines who the major players are, and how they use these technologies. Students then study the underlying technologies; examine the business problems to which they can be applied; and discuss how these problems are solved. Key companies in the spaces created by these technologies are also studied: what these companies do; which technologies they use; how these technologies support their critical applications; and how these companies compete and collaborate among themselves. Moreover, the course examines possible future directions and trends for the technologies being studied; novel applications that they enable; and how high-tech companies can leverage applications of these technologies. This is an advanced course, and it is intended for the students who have already acquired basic knowledge of technical concepts and who want to advance their knowledge of technologies beyond the basics and to further develop an understanding of the dynamics of the spaces associated with these technologies.',
instructors: ['Alex Tuzhilin'],
},
'fall3-tech-innovation-media': {
description: 'This course will look to provide a framework for understanding the various technologies impacting the media in the marketplace today - using subjects both ripped from the headlines and grounded in near-term history - as well as provide a structure for assessing the opportunities and challenges of innovations in the 3-5 year time horizon. It is designed to help students become effective marketers in the 21st century. Topics covered will include the digital home, web 2.0, social media, online video, digital advertising, video-on-demand, mobile applications, gaming, sports technologies, and interactive TV.',
instructors: ['Jamyn Edis'],
},
// === Fall Elective Set 4 ===
'fall4-turnaround': {
description: 'This course provides turnaround, restructuring, and distressed investing skills that will expose you to, and prepare you for, careers in the industry. The focus is primarily on corporate reorganizations ranging from small/mid-size businesses to large corporations, including real estate and municipal turnarounds. Topics will include the identification of distressed opportunities; distressed research analysis; and the sourcing of distressed opportunities in the US, Europe, Latin America and Asia. We will also discuss workout strategies; the fundamentals of bankruptcy and the bankruptcy-reorganization process; and career opportunities in the distressed investment business. The class will culminate with students selecting a turnaround investment idea and presenting it to the class for consideration.',
instructors: ['Joseph Sarachek'],
},
'fall4-financial-services': {
description: 'The financial services industry touches all of our lives and has been going through a continuous transformation since the deregulation that began in the early 1970\'s. That evolution has accelerated in recent years as more and more pressure has been brought to bear by various stakeholders in the industry who have divergent goals and agendas. Those invested stakeholders include clients, investors, employees, regulators/politicians and the public at large. Overlaying all of this change has been rapid technological advancement that has had a direct impact on how the industry delivers its services, meets expected equity returns and manages the risk inherent in that delivery. This course is an Advanced Finance Elective where THINKING not MEMORIZING is what it is all about.\n\nThis course is a survey course. It provides a broad overview of the financial services industry and of the forces that are continuing to change it worldwide. That change/evolution has resulted in a confederation of sometimes integrated products and services within a multi-product firm. It has also resulted in individual stand-alone businesses within those same integrated financial firms, or in boutique, stand-alone, limited product firms. The course focuses on four big questions: (1) Why and what kind of services are provided by participants in the industry? (2) Who develops, provides and regulates those services? (3) How are they likely to be executed or modified in the future? (4) What skills (both technical and "soft") are required for an individual to succeed in the industry?',
instructors: ['James Finch'],
},
'fall4-game-theory': {
description: 'Game theory studies competitive and cooperative behavior in strategic environments, where the fortunes of several players are intertwined. It provides methods for identifying optimal strategies and predicting the outcome of strategic interactions. As well as learning the underlying theory, students learn how game theory can be applied to business.\n\nThis class uses a blend of cases, lectures and simulations to understand real world phenomena through the lens of game theory. For example, we use game theory to answer questions such as: (i) Why did it take so long for Kennedy to respond the way he did in the Cuban Missile Crisis? The course will equip students with game theory techniques for making good business decisions by learning how to recognize and model strategic situations and to predict when and how actions will influence the decisions of others.',
instructors: ['Rob Seamans'],
},
'fall4-brand-strategy': {
description: 'Which brands make consumers happy? What attracts consumers to these brands? How do companies create compelling brand experiences? How could you cultivate a brand that fosters customer engagement? This course takes a customer-centric approach to explore such questions with the goal of identifying the ingredients for building and managing inspired brands.',
instructors: ['Geeta Menon'],
},
};
+13 -8
View File
@@ -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 ===
@@ -109,16 +110,20 @@ export const COURSES: Course[] = [
// === Summer Elective Set 2 ===
{
id: 'sum2-managing-growing', name: 'Managing Growing Companies', setId: 'sum2',
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: [
{ specId: 'ENT', marker: 'standard' }, { specId: 'LCM', marker: 'standard' },
{ specId: 'MGT', marker: 'standard' }, { specId: 'STR', marker: 'S1' },
{ specId: 'BRM', marker: 'standard' }, { specId: 'ENT', marker: 'standard' },
{ specId: 'MKT', marker: 'standard' }, { specId: 'STR', marker: 'S2' },
],
},
{
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',
@@ -165,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',
+1 -1
View File
@@ -27,7 +27,7 @@ export const ELECTIVE_SETS: ElectiveSet[] = [
},
{
id: 'sum2', name: 'Summer Elective Set 2', term: 'Summer',
courseIds: ['sum2-managing-growing', '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',
+29
View File
@@ -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;
}
+1
View File
@@ -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' },
+12 -9
View File
@@ -23,7 +23,7 @@ describe('analyzeDecisionTree', () => {
'spr4-fintech',
'spr5-corporate-finance',
'sum1-collaboration',
'sum2-managing-growing',
'sum2-innovation-design',
'sum3-valuation',
'fall1-private-equity',
'fall2-behavioral-finance',
@@ -50,7 +50,7 @@ describe('analyzeDecisionTree', () => {
'spr4-fintech',
'spr5-corporate-finance',
'sum1-collaboration',
'sum2-managing-growing',
'sum2-innovation-design',
'sum3-valuation',
'fall1-private-equity',
'fall2-behavioral-finance',
@@ -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',
@@ -72,20 +75,20 @@ describe('analyzeDecisionTree', () => {
'spr4-fintech',
'spr5-corporate-finance',
'sum1-collaboration',
'sum2-managing-growing',
'sum2-innovation-design',
'sum3-valuation',
'fall1-private-equity',
'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);
});
});
+134
View File
@@ -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']);
});
});
+32 -1
View File
@@ -109,6 +109,37 @@ describe('determineStatuses', () => {
// Most specs only have 1 qualifying course in spr1 (2.5 credits < 9)
expect(statuses['FIN']).toBe('unreachable');
});
it('marks spec as unreachable when infeasible alongside achieved specs due to credit sharing', () => {
// Bug scenario: CRF+STR achieved, LCM has 10 credit upper bound but
// shared courses (spr3, fall3) are consumed by CRF/STR
const selectedCourses = [
'spr1-collaboration', // LCM, MGT
'spr2-financial-services', // BNK, CRF, FIN, FIM
'spr3-mergers-acquisitions', // CRF, FIN, LCM, STR(S1)
'spr4-foundations-entrepreneurship', // ENT, MGT, STR(S1)
'spr5-corporate-finance', // CRF, FIN
'sum1-global-immersion', // GLB
'sum2-business-drivers', // STR(S1)
'sum3-valuation', // BNK, CRF, FIN, FIM
'fall1-managing-change', // LCM, MGT, STR(S2)
'fall2-decision-models', // MGT, MTO
'fall3-corporate-governance', // LCM, MGT, SBI, STR(S1)
'fall4-game-theory', // MGT, STR(S1)
];
// Baseline: LCM is achievable when no specs are achieved (upper bound alone)
const statusesBaseline = determineStatuses(selectedCourses, [], []);
expect(statusesBaseline['LCM']).toBe('achievable');
// Core bug scenario: CRF+STR achieved (without MGT), LCM should still be unreachable
const statusesWithoutMgt = determineStatuses(selectedCourses, [], ['CRF', 'STR']);
expect(statusesWithoutMgt['LCM']).toBe('unreachable');
// LCM upper bound is 10 (>= 9) but infeasible alongside CRF+STR+MGT
const statusesWithMgt = determineStatuses(selectedCourses, [], ['CRF', 'STR', 'MGT']);
expect(statusesWithMgt['LCM']).toBe('unreachable');
});
});
describe('optimize (integration)', () => {
@@ -118,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', () => {
@@ -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);
});
+449 -105
View File
@@ -1,91 +1,466 @@
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>,
): SearchResult {
const fn = mode === 'maximize-count' ? maximizeCount : priorityOrder;
const scorer = makePriorityScorer(ranking);
const upperBounds = computeUpperBounds(
pinnedCourseIds,
openSetIds,
excludedCourseIds,
);
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);
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>,
): { topK: PlanOutcome[]; setAnalyses: SetAnalysis[] } {
const upperBounds = computeUpperBounds([], openSetIds, excludedCourseIds);
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 +471,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;
}
+16 -6
View File
@@ -8,6 +8,7 @@ import {
preFilterCandidates,
computeUpperBounds,
} from './feasibility';
import { makePriorityScorer } from './priority';
const CREDIT_THRESHOLD = 9;
const CREDIT_PER_COURSE = 2.5;
@@ -67,11 +68,7 @@ export function maximizeCount(
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
const maxSize = Math.min(3, achievable.length);
@@ -178,7 +175,20 @@ export function determineStatuses(
continue;
}
statuses[spec.id] = 'achievable';
// Verify spec is actually feasible alongside already-achieved specs,
// but only when all course slots are committed (no open sets remain).
// With open sets, the user can still pick different courses, so LP
// feasibility over selected courses alone would give false negatives.
if (openSetSet.size === 0) {
const testSet = [...achieved, spec.id];
const filteredCourseIds = excludedCourseIds
? selectedCourseIds.filter((id) => !excludedCourseIds.has(id))
: selectedCourseIds;
const feasResult = checkWithS2(filteredCourseIds, testSet);
statuses[spec.id] = feasResult.feasible ? 'achievable' : 'unreachable';
} else {
statuses[spec.id] = 'achievable';
}
}
return statuses;
+53
View File
@@ -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;
};
}
+150 -12
View File
@@ -3,11 +3,27 @@ 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 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;
leaves: Map<string, PlanOutcome>;
}
const STORAGE_KEY = 'emba-solver-state';
export interface AppState {
@@ -54,7 +70,7 @@ function loadState(): AppState {
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,
@@ -70,8 +86,16 @@ 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,
leaves: new Map(),
});
// Persist to localStorage
useEffect(() => {
@@ -117,33 +141,135 @@ export function useAppState() {
[selectedCourseIds, state.ranking, openSetIds, state.mode, excludedCourseIds],
);
// 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) {
setTreeResults([]);
setTopPlans([]);
setTopPlansPartial(false);
setSearchProgress(null);
setTreeLoading(false);
return;
}
// Invalidate cache if ranking or mode has changed
const cache = leafCacheRef.current;
const sameRanking =
cache.ranking.length === state.ranking.length &&
cache.ranking.every((r, i) => r === state.ranking[i]);
if (!sameRanking || cache.mode !== state.mode) {
cache.ranking = state.ranking;
cache.mode = state.mode;
cache.leaves.clear();
}
// Compute the orderedCourses per set + expectedTotal (mirrors searchDecisionTree)
const upperBounds = computeUpperBounds(selectedCourseIds, openSetIds, excludedCourseIds);
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,
);
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 +278,16 @@ 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)),
};
worker.postMessage(request);
} catch {
// Web Worker not available (e.g., test env) — skip
setTreeLoading(false);
}
}, 300);
@@ -171,7 +299,7 @@ export function useAppState() {
workerRef.current = null;
}
};
}, [selectedCourseIds, openSetIds, state.ranking, state.mode, excludedCourseIds]);
}, [selectedCourseIds, openSetIds, state.ranking, state.mode, excludedCourseIds, pinnedAssignments]);
const reorder = useCallback((ranking: string[]) => dispatch({ type: 'reorder', ranking }), []);
const setMode = useCallback((mode: OptimizationMode) => dispatch({ type: 'setMode', mode }), []);
@@ -179,11 +307,20 @@ export function useAppState() {
const unpinCourse = useCallback((setId: string) => dispatch({ type: 'unpinCourse', setId }), []);
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,
@@ -193,5 +330,6 @@ export function useAppState() {
pinCourse,
unpinCourse,
clearAll,
adoptPlan,
};
}
+67 -20
View File
@@ -1,41 +1,88 @@
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[];
}
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,
} = 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,
);
// 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
View File
@@ -6,8 +6,8 @@ import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
define: {
__APP_VERSION__: JSON.stringify('1.1.0'),
__APP_VERSION_DATE__: JSON.stringify('2026-03-13'),
__APP_VERSION__: JSON.stringify('1.4.0'),
__APP_VERSION_DATE__: JSON.stringify('2026-05-09'),
},
server: {
allowedHosts: ['soos'],
+12 -2
View File
@@ -1,6 +1,16 @@
name: emba-course-solver
services:
app:
emba-course-solver:
build: .
container_name: emba-course-solver
ports:
- "${PORT:-8080}:80"
- "8087:80"
networks:
- default
- reverse_proxy
restart: unless-stopped
networks:
reverse_proxy:
external: true
@@ -0,0 +1,99 @@
# Achievable Status Fix Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Fix `determineStatuses` so specs that are infeasible alongside achieved specs show as "unreachable" instead of falsely "achievable."
**Architecture:** Add a feasibility check in `determineStatuses()` after the upper bound check passes. Uses the existing `checkWithS2` helper to LP-solve whether the spec can be achieved alongside the already-achieved set.
**Tech Stack:** TypeScript, Vitest, javascript-lp-solver
---
### Task 1: Write failing test for the bug scenario
**Files:**
- Modify: `app/src/solver/__tests__/optimizer.test.ts`
- [ ] **Step 1: Add test case for LCM being falsely marked achievable**
Add to the `determineStatuses` describe block:
```typescript
it('marks spec as unreachable when infeasible alongside achieved specs due to credit sharing', () => {
// Bug scenario: CRF+STR achieved, LCM has 10 credit upper bound but
// shared courses (spr3, fall3) are consumed by CRF/STR
const selectedCourses = [
'spr1-collaboration', // LCM, MGT
'spr2-financial-services', // BNK, CRF, FIN, FIM
'spr3-mergers-acquisitions', // CRF, FIN, LCM, STR(S1)
'spr4-foundations-entrepreneurship', // ENT, MGT, STR(S1)
'spr5-corporate-finance', // CRF, FIN
'sum1-global-immersion', // GLB
'sum2-business-drivers', // STR(S1)
'sum3-valuation', // BNK, CRF, FIN, FIM
'fall1-managing-change', // LCM, MGT, STR(S2)
'fall2-decision-models', // MGT, MTO
'fall3-corporate-governance', // LCM, MGT, SBI, STR(S1)
'fall4-game-theory', // MGT, STR(S1)
];
const achieved = ['CRF', 'STR', 'MGT'];
const statuses = determineStatuses(selectedCourses, [], achieved);
// LCM upper bound is 10 (>= 9) but infeasible alongside CRF+STR+MGT
expect(statuses['LCM']).toBe('unreachable');
});
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `cd /home/bill/dev/emba-course-solver/app && npx vitest run src/solver/__tests__/optimizer.test.ts -t "marks spec as unreachable when infeasible alongside achieved specs"`
Expected: FAIL — `statuses['LCM']` is `'achievable'` but expected `'unreachable'`
### Task 2: Implement the feasibility check in determineStatuses
**Files:**
- Modify: `app/src/solver/optimizer.ts:146-185`
- [ ] **Step 3: Add feasibility check after the upper bound gate**
In `app/src/solver/optimizer.ts`, in the `determineStatuses` function, replace:
```typescript
statuses[spec.id] = 'achievable';
```
(line 181) with:
```typescript
// Verify spec is actually feasible alongside already-achieved specs
const testSet = [...achieved, spec.id];
const feasResult = checkWithS2(selectedCourseIds, testSet);
statuses[spec.id] = feasResult.feasible ? 'achievable' : 'unreachable';
```
- [ ] **Step 4: Run the failing test to verify it now passes**
Run: `cd /home/bill/dev/emba-course-solver/app && npx vitest run src/solver/__tests__/optimizer.test.ts -t "marks spec as unreachable when infeasible alongside achieved specs"`
Expected: PASS
- [ ] **Step 5: Run the full test suite to check for regressions**
Run: `cd /home/bill/dev/emba-course-solver/app && npx vitest run`
Expected: All tests pass
- [ ] **Step 6: Commit**
```bash
cd /home/bill/dev/emba-course-solver
git add app/src/solver/optimizer.ts app/src/solver/__tests__/optimizer.test.ts
git commit -m "fix: mark specs as unreachable when infeasible alongside achieved specs
determineStatuses() was marking specs as 'achievable' based solely on
per-specialization upper bounds, ignoring credit sharing with achieved
specs. Now performs an LP feasibility check to verify the spec can
actually be achieved alongside the current achieved set."
```
@@ -0,0 +1,60 @@
# Fix: "Achievable" status ignores credit sharing with achieved specs
**Date:** 2026-03-27
**Type:** Bugfix
## Problem
`determineStatuses()` marks specializations as "achievable" based solely on per-specialization upper bounds (`computeUpperBounds`), which ignore credit sharing between specializations. A spec can show "achievable" (upper bound >= 9) even when it's infeasible alongside the already-achieved higher-priority specs because shared courses have committed their credits elsewhere.
**Reproduction scenario:**
- 11 courses selected, Fall 1 open
- Priority: CRF > STR > LCM > MGT
- LCM upper bound = 10 (from spr1-collaboration, spr3-M&A, fall1-managing-change, fall3-corporate-governance)
- CRF + STR achieved, consuming credits from spr3 and fall3
- LCM shows "Achievable" but `checkFeasibility([all courses], ['CRF', 'STR', 'LCM'])` is infeasible for all S2 choices
- Selecting Managing Change for Fall 1 achieves MGT (priority 4) instead of LCM (priority 3)
## Root Cause
`determineStatuses()` in `optimizer.ts:146-185` checks only:
1. Whether the spec is in the achieved set
2. Whether the required course gate passes
3. Whether `computeUpperBounds` >= 9 (per-spec, ignoring sharing)
It never checks whether the spec is actually feasible alongside the achieved set.
## Fix
In `determineStatuses()`, after a non-achieved spec passes the upper bound check, add a feasibility check: call `checkWithS2(selectedCourseIds, [...achieved, specId])`. If infeasible, mark as `unreachable` instead of `achievable`.
### Changes
**`optimizer.ts``determineStatuses` function:**
- After the upper bound check passes (currently falls through to `statuses[spec.id] = 'achievable'`), add:
```ts
const testSet = [...achieved, spec.id];
const feasResult = checkWithS2(selectedCourseIds, testSet);
if (!feasResult.feasible) {
statuses[spec.id] = 'unreachable';
continue;
}
```
- No new parameters needed — `selectedCourseIds` is already passed to the function.
### What doesn't change
- No new status types — reuses existing `unreachable`
- No UI changes — `unreachable` already renders correctly with grey styling
- `computeUpperBounds` unchanged — still used for credit bar display
- `AllocationResult` type unchanged
- `checkWithS2` helper already exists in the same file
### Test updates
- Add a test for the bug scenario: given the specific course selection and CRF > STR > LCM > MGT ranking, verify LCM status is `unreachable` (not `achievable`) when CRF and STR are achieved
- Existing test `marks achievable when required course is in open set` should be unaffected (uses all-open sets with no achieved specs, so feasibility check passes trivially)
### Performance
14 specializations total, at most ~10 non-achieved specs to check. Each check is a small LP solve. Negligible overhead.
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-03-27
@@ -0,0 +1,68 @@
## Context
The app is a React (Vite/TypeScript) single-page app with no external UI library. Courses are rendered as clickable buttons in `CourseSelection.tsx`. There is no existing tooltip or popover infrastructure. The app already handles responsive layout via a `useMediaQuery` hook.
Course descriptions and instructors come from a static PDF (`ref/J27 Electives-Course Descriptions.pdf`) plus one supplementary markdown file (`ref/inovation-and-design.md`). The data is stable per cohort (J27).
## Goals / Non-Goals
**Goals:**
- Show course description and instructor(s) inline via a popover triggered by an info icon
- Work identically on desktop and mobile (click/tap, no hover or long-press)
- Keep course description data separate from solver data structures
**Non-Goals:**
- Dynamic fetching of descriptions from an API
- Editing or updating descriptions at runtime
- Changing the Course type or solver logic
- Adding a third-party tooltip/popover library
## Decisions
### 1. Separate data file keyed by course ID
Store descriptions in `app/src/data/courseDescriptions.ts` as a `Record<string, { description: string; instructors: string[] }>` keyed by course ID.
**Why over extending Course type:** Descriptions are long strings unrelated to solver logic. Keeping them separate preserves readability of `courses.ts` and avoids polluting the `Course` interface used throughout the solver.
**Why key by course ID (not name):** The same course name can appear in multiple elective sets with different instructors (e.g., "Collaboration, Conflict and Negotiation" — Steve Blader in Spring, Elizabeth Morrison in Summer). Per-ID keying handles this correctly at the cost of some description duplication.
### 2. Info icon trigger (not hover/long-press)
An `(i)` icon button next to each course name, clickable on all platforms.
**Why over CSS hover tooltip:** Hover doesn't work on touch devices. A separate info icon avoids conflicting with the existing click-to-select behavior on the course button.
**Why over long-press on mobile:** Long-press is discoverable only if users know to try it. An explicit icon is universally obvious.
### 3. Pure CSS/React popover (no library)
Build the popover as a positioned `div` managed with React state. Close on: click outside (document listener), re-click icon, or Escape key.
**Why no library:** The app has zero UI dependencies beyond React. A single popover component doesn't justify adding one. The positioning logic is straightforward since popovers anchor to a known icon element.
### 4. Popover content layout
```
┌────────────────────────────────┐
│ Course Name │
│ Instructor(s): Name, Name │
│ ────────────────────────────── │
│ Description text, scrollable │
│ if longer than max-height... │
└────────────────────────────────┘
```
- Fixed max-width (~320px), max-height (~300px) with overflow scroll
- Instructor list shown as comma-separated
- Close button (X) in top-right corner
### 5. Event handling: stop propagation on info icon
The `(i)` icon click must call `e.stopPropagation()` to prevent the parent course button's `onClick` (which pins the course) from firing.
## Risks / Trade-offs
- **Description duplication across sets** — Same description text stored under multiple course IDs (e.g., `spr1-collaboration` and `sum1-collaboration`). Acceptable given the small dataset (~35 entries) and the need for per-ID instructor differentiation.
- **Popover positioning at screen edges** — A simple anchored popover could overflow the viewport on narrow screens. Mitigation: on mobile, render as a near-full-width card or use `position: fixed` centered overlay instead of anchored positioning.
- **Stale data if courses change** — Descriptions are hardcoded. If the course list changes for a future cohort, both `courses.ts` and `courseDescriptions.ts` need updating. This matches the existing pattern (all course data is static).
@@ -0,0 +1,26 @@
## Why
Users selecting elective courses have no way to see course descriptions or instructor information within the app. They must cross-reference a separate PDF document to understand what each course covers. Adding inline course info popups reduces friction and helps users make more informed selections.
## What Changes
- Add a new data file mapping each course ID to its description text and instructor list, extracted from the J27 Electives PDF and supplementary sources
- Add an info icon `(i)` next to each course name in the course selection UI
- Clicking/tapping the info icon opens a popover displaying the course description and instructor(s)
- Popover closes on click outside, clicking the icon again, or pressing Escape
- Works identically on desktop and mobile (no hover/long-press distinction)
## Capabilities
### New Capabilities
- `course-info-popover`: Inline popover UI triggered by an info icon on each course, displaying course description and instructor(s) from a static data source
### Modified Capabilities
## Impact
- New file: `app/src/data/courseDescriptions.ts` — static data (~35 course entries with descriptions and instructor arrays)
- Modified: `app/src/data/types.ts` — no changes needed (data lives in separate lookup, not on Course type)
- Modified: `app/src/components/CourseSelection.tsx` — add info icon and popover component
- No solver, state, or worker changes
- No new dependencies
@@ -0,0 +1,80 @@
## ADDED Requirements
### Requirement: Course description data source
The system SHALL maintain a static data file mapping each course ID to its description text and instructor list. Each entry SHALL contain a `description` string and an `instructors` string array. Only courses present in `courses.ts` SHALL have entries.
#### Scenario: Course with single instructor
- **WHEN** looking up course ID `spr2-consumer-behavior`
- **THEN** the entry contains a description string and `instructors: ["Radhika Duggal"]`
#### Scenario: Course with multiple instructors
- **WHEN** looking up course ID `spr1-high-stakes`
- **THEN** the entry contains `instructors: ["Steve Mellas", "Jim Donofrio"]`
#### Scenario: Same course name in different sets has per-ID entries
- **WHEN** looking up `spr1-collaboration` and `sum1-collaboration`
- **THEN** both have description entries, and instructors MAY differ between them
### Requirement: Info icon display
Each course button in the course selection UI SHALL display a clickable info icon next to the course name. The icon SHALL be visible for all non-cancelled, non-disabled courses.
#### Scenario: Info icon visible on available course
- **WHEN** a course is not cancelled and not disabled
- **THEN** an info icon is displayed next to the course name
#### Scenario: Info icon hidden on cancelled course
- **WHEN** a course is cancelled
- **THEN** no info icon is displayed
#### Scenario: Info icon hidden on disabled course
- **WHEN** a course is disabled (already selected in another set)
- **THEN** no info icon is displayed
### Requirement: Popover opens on info icon click
Clicking or tapping the info icon SHALL open a popover displaying the course description and instructor(s). Clicking the info icon SHALL NOT trigger course selection (pin).
#### Scenario: Open popover on click
- **WHEN** user clicks the info icon on a course
- **THEN** a popover appears showing the course name, instructor(s), and description
- **THEN** the course is NOT pinned/selected
#### Scenario: Only one popover open at a time
- **WHEN** a popover is open and user clicks a different course's info icon
- **THEN** the first popover closes and the new one opens
### Requirement: Popover content layout
The popover SHALL display the course name as a heading, instructor(s) as a comma-separated list, and the full description text. If the description exceeds the popover's max height, the content SHALL be scrollable.
#### Scenario: Display with single instructor
- **WHEN** popover opens for a course with one instructor
- **THEN** it shows "Instructor: Name"
#### Scenario: Display with multiple instructors
- **WHEN** popover opens for a course with multiple instructors
- **THEN** it shows "Instructors: Name1, Name2"
#### Scenario: Long description scrollable
- **WHEN** popover opens for a course with a long description
- **THEN** the popover has a max height and the content area is scrollable
### Requirement: Popover dismissal
The popover SHALL close when the user clicks outside it, clicks the info icon again, or presses the Escape key.
#### Scenario: Close on click outside
- **WHEN** a popover is open and user clicks outside of it
- **THEN** the popover closes
#### Scenario: Close on Escape key
- **WHEN** a popover is open and user presses Escape
- **THEN** the popover closes
#### Scenario: Close on re-click info icon
- **WHEN** a popover is open and user clicks the same info icon
- **THEN** the popover closes
### Requirement: Mobile and desktop parity
The popover interaction SHALL work identically on desktop and mobile via click/tap. No hover or long-press interactions are used.
#### Scenario: Mobile tap opens popover
- **WHEN** user taps the info icon on a mobile device
- **THEN** the popover opens, same as desktop click behavior
@@ -0,0 +1,21 @@
## 1. Data Layer
- [x] 1.1 Create `app/src/data/courseDescriptions.ts` with description and instructors array for each course ID, extracted from the PDF and `ref/inovation-and-design.md`
- [x] 1.2 Verify all course IDs in `courses.ts` have corresponding entries (no missing, no extras)
## 2. Popover Component
- [x] 2.1 Build a `CourseInfoPopover` component that displays course name, instructor(s), and scrollable description
- [x] 2.2 Add dismiss logic: click outside, Escape key, re-click info icon
- [x] 2.3 Ensure only one popover is open at a time
## 3. Info Icon Integration
- [x] 3.1 Add info icon button next to each course name in `CourseSelection.tsx`
- [x] 3.2 Use `stopPropagation` on info icon click to prevent course pinning
- [x] 3.3 Hide info icon for cancelled and disabled courses
## 4. Responsive / Polish
- [x] 4.1 Handle popover positioning on narrow screens (centered overlay or full-width card on mobile)
- [x] 4.2 Verify desktop and mobile parity (click/tap only, no hover/long-press)
@@ -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(secondsminutes) 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** (50500 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 (35) 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 (35 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._
+52
View File
@@ -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-03-27
@@ -0,0 +1,26 @@
## Context
Summer Elective Set 2 currently contains a cancelled course "Managing Growing Companies" (`sum2-managing-growing`) marked with `cancelled: true`. It qualifies for ENT, LCM, MGT, and STR (S1). A replacement course "Innovation and Design" has been announced, qualifying for BRM, ENT, MKT, and STR (S2).
## Goals / Non-Goals
**Goals:**
- Replace the cancelled course data entry with the new course
- Update all test assertions to match the new data
**Non-Goals:**
- No UI changes needed — the `cancelled` flag removal and new course entry are handled by existing rendering logic
- No solver logic changes — the solver already excludes cancelled courses and will automatically include the new one
## Decisions
### Direct replacement over keeping both entries
Replace the cancelled course entry rather than adding a new entry alongside it. The cancelled course has no historical value in the UI since the app is a forward-looking course selection tool. This keeps the course count at 46 and avoids unnecessary complexity.
### Specialization marker choices
All qualifications use `standard` marker except Strategy which uses `S2` (per user specification). This shifts one Strategy course from S1 pool to S2 pool.
## Risks / Trade-offs
- **STR S1 pool shrinks from 10 to 9** — Minor reduction in S1-qualifying courses. No mitigation needed; this reflects the actual curriculum change.
- **LCM and MGT lose coverage in sum2** — These specializations no longer have a qualifying course in Summer Set 2. This accurately reflects the new course's different focus.
@@ -0,0 +1,30 @@
## Why
The cancelled course "Managing Growing Companies" in Summer Elective Set 2 needs to be replaced with a new course "Innovation and Design" that counts towards different specializations (BRM, ENT, MKT, STR-S2 instead of ENT, LCM, MGT, STR-S1).
## What Changes
- Remove the cancelled course entry `sum2-managing-growing` ("Managing Growing Companies") from the course data
- Add a new course `sum2-innovation-design` ("Innovation and Design") in Summer Set 2 with qualifications: Brand Management (BRM), Entrepreneurship and Innovation (ENT), Marketing (MKT), Strategy (STR, S2 marker)
- Update test assertions to reflect changed specialization counts and Strategy marker counts
## Capabilities
### New Capabilities
_None — this is a data change, not a new capability._
### Modified Capabilities
_None — no spec-level behavior changes, only course data._
## Impact
- `app/src/data/courses.ts` — replace cancelled course entry with new course
- `app/src/data/__tests__/data.test.ts` — update assertions:
- STR S1 count: 10 → 9
- STR S2 count: 7 → 8
- BRM across sets: 6 → 7
- MKT across sets: 7 → 8
- LCM across sets: 9 → 8
- MGT across sets: 11 → 10
@@ -0,0 +1,18 @@
## ADDED Requirements
### Requirement: Innovation and Design course in Summer Set 2
The system SHALL include "Innovation and Design" as an active course in Summer Elective Set 2 (`sum2`), qualifying for Brand Management (BRM), Entrepreneurship and Innovation (ENT), Marketing (MKT), and Strategy (STR, S2 marker).
#### Scenario: Course appears in Summer Set 2
- **WHEN** the user views Summer Elective Set 2
- **THEN** "Innovation and Design" appears as a selectable course
#### Scenario: Course qualifies for correct specializations
- **WHEN** the user selects "Innovation and Design"
- **THEN** it contributes credits towards BRM, ENT, MKT, and STR (S2)
## REMOVED Requirements
### Requirement: Managing Growing Companies course
**Reason**: Course has been cancelled by the program and replaced by "Innovation and Design"
**Migration**: No migration needed — the cancelled course was already excluded from solver computations
@@ -0,0 +1,17 @@
## 1. Course Data
- [x] 1.1 Replace `sum2-managing-growing` entry in `app/src/data/courses.ts` with new `sum2-innovation-design` course: name "Innovation and Design", setId "sum2", qualifications BRM (standard), ENT (standard), MKT (standard), STR (S2)
## 2. Test Assertions
- [x] 2.1 Update STR marker counts in `app/src/data/__tests__/data.test.ts`: S1 from 10 to 9, S2 from 7 to 8
- [x] 2.2 Update per-specialization "across sets" counts: BRM 6→7, MKT 7→8, LCM 9→8, MGT 11→10
## 3. Version and Changelog
- [x] 3.1 Bump version to `1.1.1` and update date in `app/vite.config.ts` (`__APP_VERSION__` and `__APP_VERSION_DATE__`)
- [x] 3.2 Add v1.1.1 entry to `CHANGELOG.md` documenting the course replacement
## 4. Verification
- [x] 4.1 Run test suite and confirm all tests pass