From cb2024f857003130611bed58bdc7b19bb26aa13d Mon Sep 17 00:00:00 2001 From: Bill Ballou Date: Sun, 10 May 2026 11:57:56 -0400 Subject: [PATCH] v1.5.1: Show completed plan in Top Plans Once every elective set is pinned, the Top Plans panel now renders the user's completed selection as a single "Your Plan" card showing the achievement count and pinned courses. Previously the panel went blank because the all-pinned branch in useAppState cleared topPlans, even though the spec strip was already showing the achievement. Synthesizes a single PlanOutcome from pinnedAssignments + optimizationResult.achieved + the priority scorer; TopPlans detects the all-pinned state via ELECTIVE_SETS.every(...) and bypasses the length>0 filter so a 0-spec completed plan still renders honestly. --- CHANGELOG.md | 8 ++ app/src/components/TopPlans.tsx | 8 +- app/src/state/appState.ts | 11 +- app/vite.config.ts | 2 +- .../show-completed-plan/.openspec.yaml | 2 + .../changes/show-completed-plan/design.md | 111 ++++++++++++++++++ .../changes/show-completed-plan/proposal.md | 30 +++++ .../specs/optimization-engine/spec.md | 23 ++++ .../unified-specialization-panel/spec.md | 32 +++++ openspec/changes/show-completed-plan/tasks.md | 24 ++++ 10 files changed, 246 insertions(+), 5 deletions(-) create mode 100644 openspec/changes/show-completed-plan/.openspec.yaml create mode 100644 openspec/changes/show-completed-plan/design.md create mode 100644 openspec/changes/show-completed-plan/proposal.md create mode 100644 openspec/changes/show-completed-plan/specs/optimization-engine/spec.md create mode 100644 openspec/changes/show-completed-plan/specs/unified-specialization-panel/spec.md create mode 100644 openspec/changes/show-completed-plan/tasks.md diff --git a/CHANGELOG.md b/CHANGELOG.md index cf736d6..8f5a8eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## v1.5.1 — 2026-05-10 + +### Changes + +- **Top Plans surfaces the completed plan** — once every elective set is pinned, the panel renders the user's completed selection as a single "Your Plan" card showing the achievement count and the 12 pinned courses. Previously the panel went blank with a misleading "No plans yet…" placeholder, even though the spec strip was already showing the achievement. +- **0-spec completed plan still renders** — when the pinned selection happens to achieve no specializations, the plan card now shows `0` achievements honestly instead of falling back to the search-style placeholder. +- **Header copy** — the panel header switches to "Your Plan" (singular, no "ranked by specs achieved" subtitle) when selection is complete; reverts to "Top Plans" on unpin. + ## v1.5.0 — 2026-05-10 ### Changes diff --git a/app/src/components/TopPlans.tsx b/app/src/components/TopPlans.tsx index 426255b..11ef9f1 100644 --- a/app/src/components/TopPlans.tsx +++ b/app/src/components/TopPlans.tsx @@ -38,7 +38,9 @@ function formatScore(n: number): string { 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 allPinned = ELECTIVE_SETS.every((s) => pinnedCourses[s.id]); + const visible = allPinned ? plans : plans.filter((p) => p.achievedSpecs.length > 0); + const heading = allPinned ? 'Your Plan' : 'Top Plans'; const pct = progress && progress.iterationsTotal > 0 ? Math.min(100, (progress.iterations / progress.iterationsTotal) * 100) @@ -55,8 +57,8 @@ export function TopPlans({ plans, partial, loading, progress, pinnedCourses, ran

- Top Plans - {visible.length > 0 && ( + {heading} + {!allPinned && visible.length > 0 && ( ranked by specs achieved diff --git a/app/src/state/appState.ts b/app/src/state/appState.ts index 3d6336c..fd6dca4 100644 --- a/app/src/state/appState.ts +++ b/app/src/state/appState.ts @@ -11,6 +11,7 @@ import { selectPriorityTarget, } from '../solver/decisionTree'; import { computeUpperBounds } from '../solver/feasibility'; +import { makePriorityScorer } from '../solver/priority'; import type { SetAnalysis, PlanOutcome } from '../solver/decisionTree'; import type { WorkerRequest, WorkerResponse } from '../workers/decisionTree.worker'; import { cancelledCourseIds, courseIdsByName, courseById } from '../data/lookups'; @@ -183,8 +184,16 @@ export function useAppState() { if (debounceRef.current) clearTimeout(debounceRef.current); if (openSetIds.length === 0) { + // Selection is complete — synthesize a single PlanOutcome from the + // pinned assignments so Top Plans can render the user's completed plan. + const scorer = makePriorityScorer(state.ranking); + const completed: PlanOutcome = { + courseAssignments: pinnedAssignments, + achievedSpecs: optimizationResult.achieved, + priorityScore: scorer(optimizationResult.achieved), + }; setTreeResults([]); - setTopPlans([]); + setTopPlans([completed]); setTopPlansPartial(false); setSearchProgress(null); setTreeLoading(false); diff --git a/app/vite.config.ts b/app/vite.config.ts index ec80420..61af2d2 100644 --- a/app/vite.config.ts +++ b/app/vite.config.ts @@ -6,7 +6,7 @@ import react from '@vitejs/plugin-react' export default defineConfig({ plugins: [react()], define: { - __APP_VERSION__: JSON.stringify('1.5.0'), + __APP_VERSION__: JSON.stringify('1.5.1'), __APP_VERSION_DATE__: JSON.stringify('2026-05-10'), }, server: { diff --git a/openspec/changes/show-completed-plan/.openspec.yaml b/openspec/changes/show-completed-plan/.openspec.yaml new file mode 100644 index 0000000..ac20efa --- /dev/null +++ b/openspec/changes/show-completed-plan/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-10 diff --git a/openspec/changes/show-completed-plan/design.md b/openspec/changes/show-completed-plan/design.md new file mode 100644 index 0000000..b67c08f --- /dev/null +++ b/openspec/changes/show-completed-plan/design.md @@ -0,0 +1,111 @@ +## Context + +`useAppState` runs a decision-tree search via a debounced worker effect to populate `topPlans`. When `openSetIds.length === 0` (every set pinned), the effect short-circuits at `appState.ts:185-192`: + +```ts +if (openSetIds.length === 0) { + setTreeResults([]); + setTopPlans([]); + setTopPlansPartial(false); + setSearchProgress(null); + setTreeLoading(false); + return; +} +``` + +Meanwhile, `optimizationResult` (a separate `useMemo` of `optimize(...)`) IS computed for the same pinned set, and the spec strip shows the correct achievement count. The two views drift out of sync exactly when the user has just finished selecting their courses — the moment when "what did I end up with?" feedback matters most. + +`TopPlans.tsx:41` further filters out plans with no achievement (`visible = plans.filter(p => p.achievedSpecs.length > 0)`), and renders "No plans yet achieve a specialization…" (lines 100-104) when nothing is visible. So even if we fed a synthesized plan with zero achievements through, today's filter would still hide it. + +## Goals / Non-Goals + +**Goals:** +- When all sets are pinned, Top Plans shows the user's completed plan with its actual achievement count +- Header reads "Your Plan" so the singular plan doesn't feel like a search result +- A 0-spec completed plan still renders (the courses are visible; the count is honest) +- No regression in the open-sets-> 0 cases (search-driven top-K, partial cache hits, streaming improvements) + +**Non-Goals:** +- New `PlanRow` styling for the completed-plan case +- Removing or hiding the adopt-button (every set is already pinned, so the button is a no-op but harmless) +- Restructuring the search progress strip +- Caching the synthesized plan (it's free to compute on every render) + +## Decisions + +### Synthesize a single `PlanOutcome` in the appState early return + +Replace the `setTopPlans([])` line with: + +```ts +const scorer = makePriorityScorer(state.ranking); +const completed: PlanOutcome = { + courseAssignments: pinnedAssignments, + achievedSpecs: optimizationResult.achieved, + priorityScore: scorer(optimizationResult.achieved), +}; +setTopPlans([completed]); +``` + +`pinnedAssignments` is already computed earlier in the hook. `optimizationResult.achieved` is already available. `makePriorityScorer` is already imported (used elsewhere in the solver layer, can be imported here cheaply). + +**Alternative considered:** Build a parallel "completed plan" panel component and switch on `openSetIds.length === 0`. Rejected — the existing `PlanRow` already renders exactly the data we need. Adding a parallel layout is more code and more drift surface for one edge case. + +### Drop the `achievedSpecs.length > 0` filter when there's a single completed plan + +`TopPlans.tsx` currently does: + +```ts +const visible = plans.filter((p) => p.achievedSpecs.length > 0); +``` + +For the all-pinned case, the synthesized plan must render even when the achievement is empty. The cleanest signal is to detect "is this a completed plan" by inspecting `pinnedCourses` (every elective set has a pinned course), and skip the filter in that case: + +```ts +const allPinned = ELECTIVE_SETS.every((s) => pinnedCourses[s.id]); +const visible = allPinned ? plans : plans.filter((p) => p.achievedSpecs.length > 0); +``` + +`pinnedCourses` is already a prop on `TopPlans`. No new wiring. + +**Alternative considered:** Drop the filter unconditionally. Rejected — when the worker is mid-search and only emitted low-achievement candidates, the existing "Searching for high-priority plans…" placeholder is the right UX. The filter exists for a reason; we just need to gate it on whether selection is complete. + +### Header copy: "Your Plan" when complete, "Top Plans" otherwise + +```ts +const heading = allPinned ? 'Your Plan' : 'Top Plans'; +``` + +The "ranked by specs achieved" subtitle stays on the multi-plan path. The single-plan path drops it (one plan can't be ranked). + +### Empty placeholder copy update + +When `!loading && visible.length === 0` and `allPinned` is true, the existing "No plans yet achieve a specialization…" message would be misleading (selection IS complete). Since the synthesis above guarantees `visible.length === 1` in the all-pinned branch, this placeholder will never fire in that path. No copy change needed there. + +### Adopt-button behavior + +The adopt button calls `onAdopt(plan.courseAssignments)`, which dispatches `pinCourse` for every set. When everything is already pinned, this is a no-op (idempotent). Leaving it visible is harmless. Hiding it would add a special case for no benefit. + +## Risks / Trade-offs + +- **Synthesized plan vs. eventual full search** → No risk. The all-pinned branch returns before the search effect runs, so there's no race where the worker overwrites the synthesized plan. +- **`allPinned` check uses `ELECTIVE_SETS.every(...)`** → Imports `ELECTIVE_SETS` into `TopPlans.tsx`. Already imported there (used for `setNameById`). No new dependency. +- **Synthesizing on every render** → `useMemo` not strictly required (the synthesis is cheap), but the existing pattern in `useAppState` uses `setTopPlans` once per effect run, which is fine. +- **Unpin-after-complete UX** → When the user unpins a single course after completing the plan, `openSetIds.length` becomes 1, the effect re-runs as a normal partial-cache search, and the synthesized plan path is no longer used. The cached subset (if any) renders immediately, exactly as today. Verified by inspection — no special handling needed. +- **External-credits achievement** → `optimizationResult.achieved` is already external-credit-aware (per the prior change). No additional plumbing. + +## Migration Plan + +Single-PR change. No data migration. No persisted state changes. + +1. Update `useAppState` to synthesize the completed plan in the all-pinned branch. +2. Update `TopPlans` to detect the all-pinned case and switch the header copy and visibility filter. +3. Browser verify: pin all 12 sets, watch Top Plans render the completed plan with the same achievement count as the spec strip. Unpin one set: search resumes normally. +4. Bump version to `1.5.1`; CHANGELOG entry. + +Rollback: revert. v1.5.0 behavior restored. + +## Open Questions + +- Should the adopt button on the synthesized plan be replaced with a "Reset" or hidden? Defer to UI polish — keeping it inert is the smallest change. +- Should the schedule panel get a "plan complete" affordance too? Out of scope for this change; could be a follow-up. diff --git a/openspec/changes/show-completed-plan/proposal.md b/openspec/changes/show-completed-plan/proposal.md new file mode 100644 index 0000000..9e8b1b4 --- /dev/null +++ b/openspec/changes/show-completed-plan/proposal.md @@ -0,0 +1,30 @@ +## Why + +When a student pins a course in every elective set, the Top Plans panel goes blank and shows the placeholder "No plans yet achieve a specialization with the current pinned courses." This is misleading: the optimizer has already computed achievement (visible in the spec strip), but the Top Plans state is short-circuited to `[]` because no decision-tree search runs when there are no open sets. The student's completed plan IS the only plan — it should be the one rendered. + +## What Changes + +- When `openSetIds.length === 0`, synthesize a single `PlanOutcome` from the pinned assignments + `optimizationResult.achieved` + the priority scorer, and surface it through the existing `topPlans` state slice instead of clearing the slice. +- Render the synthesized plan unconditionally (no `achievedSpecs.length > 0` filter) so a 0-spec completed plan still shows the courses-and-zero-achievements summary rather than the search placeholder. +- Header copy switches to "Your Plan" (singular) when `openSetIds.length === 0`. Otherwise stays "Top Plans". +- Leaf cache, search progress strip, "ranked by specs achieved" subtitle, and the search-complete/incomplete static text remain unchanged. + +## Capabilities + +### New Capabilities + +_None._ + +### Modified Capabilities + +- `optimization-engine`: when all elective sets are pinned, `useAppState` SHALL emit a single synthesized `PlanOutcome` rather than clearing the top-K slice. +- `unified-specialization-panel`: the Top Plans panel SHALL render a synthesized completed plan with header copy "Your Plan" and SHALL NOT filter out a single completed plan with zero achievements. + +## Impact + +- `app/src/state/appState.ts` — replace the `setTopPlans([])` early return with a synthesized one-element top-K when `openSetIds.length === 0`. Use `makePriorityScorer(state.ranking)(achieved)` for the score. +- `app/src/components/TopPlans.tsx` — when `plans.length === 1` and the only plan's `courseAssignments` covers every elective set, render it as "Your Plan" (header copy). Drop the `length > 0` visibility filter for that single-plan case so a 0-spec completed plan still renders. Other rendering paths unchanged. +- No new tests strictly required, but a state-layer assertion for the synthesis would be nice. Existing 97 tests must continue to pass. +- `app/vite.config.ts` — patch version bump (e.g., `1.5.1`). +- `CHANGELOG.md` — release entry. +- No data-file changes. diff --git a/openspec/changes/show-completed-plan/specs/optimization-engine/spec.md b/openspec/changes/show-completed-plan/specs/optimization-engine/spec.md new file mode 100644 index 0000000..4d08700 --- /dev/null +++ b/openspec/changes/show-completed-plan/specs/optimization-engine/spec.md @@ -0,0 +1,23 @@ +## ADDED Requirements + +### Requirement: Synthesized completed plan when all sets are pinned +When `openSetIds.length === 0` (every elective set has a pinned course), `useAppState` SHALL emit a single-element `topPlans` array containing a synthesized `PlanOutcome` rather than clearing the slice. The synthesized plan's fields SHALL be: + +- `courseAssignments`: the `pinnedAssignments` map (setId → courseId for every elective set) +- `achievedSpecs`: `optimizationResult.achieved` (the optimizer's output for the same selection) +- `priorityScore`: `makePriorityScorer(state.ranking)(achievedSpecs)` + +The other state slices (`treeResults`, `topPlansPartial`, `searchProgress`, `treeLoading`) SHALL remain in their cleared/null state for this branch — no decision-tree search runs when there are no open sets. + +#### Scenario: Top Plans state populates after every set is pinned +- **WHEN** the user pins a course in the final remaining open set +- **THEN** `topPlans` SHALL contain exactly one element whose `courseAssignments` matches the user's pinned selection +- **AND** that element's `achievedSpecs` SHALL equal `optimizationResult.achieved` + +#### Scenario: Search progress remains null when selection is complete +- **WHEN** every elective set is pinned +- **THEN** `searchProgress` SHALL be `null` and `treeLoading` SHALL be `false` + +#### Scenario: Unpinning resumes normal search +- **WHEN** the user unpins one course after the synthesized state was emitted +- **THEN** the search effect SHALL run normally (cache filter, partial worker spawn) and `topPlans` SHALL reflect the cached subset and any streamed improvements diff --git a/openspec/changes/show-completed-plan/specs/unified-specialization-panel/spec.md b/openspec/changes/show-completed-plan/specs/unified-specialization-panel/spec.md new file mode 100644 index 0000000..99e631c --- /dev/null +++ b/openspec/changes/show-completed-plan/specs/unified-specialization-panel/spec.md @@ -0,0 +1,32 @@ +## ADDED Requirements + +### Requirement: Top Plans renders the synthesized completed plan +The `TopPlans` panel SHALL detect the all-pinned state by checking that every elective set in `ELECTIVE_SETS` has a non-null `pinnedCourses[setId]`. In that state, the panel SHALL bypass the existing `achievedSpecs.length > 0` visibility filter and render every plan in the input list (which is guaranteed to be exactly one synthesized plan). + +#### Scenario: Completed plan with achievements renders normally +- **WHEN** the user has pinned every set and the synthesized plan reports 3 achieved specs +- **THEN** the panel SHALL render a single `PlanRow` showing those 3 specs and the 12 pinned courses + +#### Scenario: Completed plan with zero achievements still renders +- **WHEN** the user has pinned every set but the achieved set is empty +- **THEN** the panel SHALL render a single `PlanRow` showing `0` achievements and the 12 pinned courses +- **AND** the panel SHALL NOT show the "No plans yet achieve a specialization…" placeholder + +### Requirement: Top Plans header reflects single completed plan +When the all-pinned state is detected, the panel header SHALL read "Your Plan" instead of "Top Plans". The "ranked by specs achieved" subtitle SHALL be omitted when only the single completed plan is shown. + +#### Scenario: Header copy switches when selection is complete +- **WHEN** every elective set is pinned +- **THEN** the panel header SHALL display "Your Plan" +- **AND** the "ranked by specs achieved" subtitle SHALL NOT appear + +#### Scenario: Header copy reverts on unpin +- **WHEN** the user unpins a course after a completed-plan state +- **THEN** the panel header SHALL revert to "Top Plans" and the subtitle SHALL reappear once the multi-plan list is populated + +### Requirement: Adopt button remains harmless on the synthesized plan +The adopt button on the synthesized plan SHALL remain interactive. Clicking it SHALL be a no-op in effect (every set is already pinned to that course) but SHALL NOT throw or otherwise misbehave. + +#### Scenario: Adopt on completed plan +- **WHEN** the user clicks adopt on the synthesized completed plan +- **THEN** the application state SHALL remain unchanged and no error SHALL occur diff --git a/openspec/changes/show-completed-plan/tasks.md b/openspec/changes/show-completed-plan/tasks.md new file mode 100644 index 0000000..d1a2e32 --- /dev/null +++ b/openspec/changes/show-completed-plan/tasks.md @@ -0,0 +1,24 @@ +## 1. State synthesis + +- [x] 1.1 In `app/src/state/appState.ts`, import `makePriorityScorer` from `../solver/priority` +- [x] 1.2 Replace the `setTopPlans([])` line in the `openSetIds.length === 0` early-return branch with a synthesized single-element top-K: `{ courseAssignments: pinnedAssignments, achievedSpecs: optimizationResult.achieved, priorityScore: scorer(optimizationResult.achieved) }`. Keep `setTreeResults([])`, `setTopPlansPartial(false)`, `setSearchProgress(null)`, `setTreeLoading(false)` as-is +- [x] 1.3 Confirm the synthesized state respects external credits implicitly via `optimizationResult.achieved` (no extra plumbing needed) + +## 2. UI: detect all-pinned and render + +- [x] 2.1 In `app/src/components/TopPlans.tsx`, compute `const allPinned = ELECTIVE_SETS.every((s) => pinnedCourses[s.id])` +- [x] 2.2 Switch the visibility filter: `const visible = allPinned ? plans : plans.filter((p) => p.achievedSpecs.length > 0)` +- [x] 2.3 Switch the header text: render `"Your Plan"` when `allPinned`, otherwise `"Top Plans"` +- [x] 2.4 Hide the "ranked by specs achieved" subtitle when `allPinned` + +## 3. Tests + verification + +- [x] 3.1 Run the existing 97-test suite; confirm all still pass +- [ ] 3.2 Browser verify: pin a single course in every set and observe that Top Plans now renders the completed plan with the same achievement count as the spec strip; header reads "Your Plan" +- [ ] 3.3 Browser verify the 0-spec edge case: pin courses that yield no achievement; the panel still shows the plan with `0` achievements (no "No plans yet…" placeholder) +- [ ] 3.4 Browser verify unpin: unpin one set, header reverts to "Top Plans", search runs normally + +## 4. Version + changelog + +- [x] 4.1 Bump `__APP_VERSION__` and `__APP_VERSION_DATE__` in `app/vite.config.ts` (e.g., `1.5.1`) +- [x] 4.2 Add a `CHANGELOG.md` entry: completed plan now surfaces in the Top Plans panel as "Your Plan" once every set is pinned; was previously empty