diff --git a/docs/superpowers/plans/2026-03-27-achievable-status-fix.md b/docs/superpowers/plans/2026-03-27-achievable-status-fix.md new file mode 100644 index 0000000..6a5de02 --- /dev/null +++ b/docs/superpowers/plans/2026-03-27-achievable-status-fix.md @@ -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." +``` diff --git a/docs/superpowers/specs/2026-03-27-achievable-status-fix-design.md b/docs/superpowers/specs/2026-03-27-achievable-status-fix-design.md new file mode 100644 index 0000000..5bd445b --- /dev/null +++ b/docs/superpowers/specs/2026-03-27-achievable-status-fix-design.md @@ -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. diff --git a/openspec/changes/course-description-popups/.openspec.yaml b/openspec/changes/course-description-popups/.openspec.yaml new file mode 100644 index 0000000..a61e7c1 --- /dev/null +++ b/openspec/changes/course-description-popups/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-03-27 diff --git a/openspec/changes/course-description-popups/design.md b/openspec/changes/course-description-popups/design.md new file mode 100644 index 0000000..c7677ff --- /dev/null +++ b/openspec/changes/course-description-popups/design.md @@ -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` 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). diff --git a/openspec/changes/course-description-popups/proposal.md b/openspec/changes/course-description-popups/proposal.md new file mode 100644 index 0000000..6b0dde0 --- /dev/null +++ b/openspec/changes/course-description-popups/proposal.md @@ -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 diff --git a/openspec/changes/course-description-popups/specs/course-info-popover/spec.md b/openspec/changes/course-description-popups/specs/course-info-popover/spec.md new file mode 100644 index 0000000..0e500a6 --- /dev/null +++ b/openspec/changes/course-description-popups/specs/course-info-popover/spec.md @@ -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 diff --git a/openspec/changes/course-description-popups/tasks.md b/openspec/changes/course-description-popups/tasks.md new file mode 100644 index 0000000..b13ff5b --- /dev/null +++ b/openspec/changes/course-description-popups/tasks.md @@ -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)