## 1. Solver: external-credit-aware feasibility and bounds - [x] 1.1 In `app/src/solver/feasibility.ts`, extend `checkFeasibility` to accept `externalCredits?: Record`. Before building per-spec demand constraints, compute `adjusted = max(0, CREDIT_THRESHOLD - (externalCredits[specId] ?? 0))`. If `adjusted === 0`, omit both the `need_` constraint and any `x__` variables for that spec entirely. Otherwise set `need_ = { min: adjusted }` - [x] 1.2 Decide how to count externally-met specs in `result.allocations`: emit no in-program allocations for them (the LP doesn't allocate to them), and let `optimizer.determineStatuses` mark them as `achieved` based on the achieved-set rather than allocation totals. Document this in a one-line comment in `feasibility.ts` - [x] 1.3 Extend `computeUpperBounds` to accept `externalCredits?: Record` and add `(externalCredits[spec.id] ?? 0)` to each spec's potential before storing the bound - [x] 1.4 Extend `preFilterCandidates` to accept `externalCredits?: Record` and use the boosted potential when comparing against `CREDIT_THRESHOLD` ## 2. Solver: optimizer ceiling and status - [x] 2.1 In `app/src/solver/optimizer.ts`, thread `externalCredits?: Record` through `maximizeCount`, `priorityOrder`, `determineStatuses`, `optimize`, and `checkWithS2`. All call sites pass through (workers, app state) - [x] 2.2 Keep the hard 3-spec cap in `maximizeCount` (`const maxSize = Math.min(3, achievable.length)`). External credits may substitute into the 3 but never raise the count above 3 (program policy) - [x] 2.3 In `determineStatuses`, ensure `missing_required` precedence is preserved: when `spec.requiredCourseId` is unsatisfied, status remains `missing_required` regardless of `externalCredits[spec.id]` - [x] 2.4 Keep `priorityOrder`'s `if (achieved.length >= 3) break;` guard alongside the maxSize cap, mirroring `maximizeCount` ## 3. Solver: decision tree + worker plumbing - [x] 3.1 In `app/src/solver/decisionTree.ts`, thread `externalCredits?: Record` into `searchDecisionTree`, `evaluateLeaf`, `reorderForTarget`, `reorderByReachableQualCount`, and any helper that calls `computeUpperBounds`/`optimize`/`checkFeasibility`. Default to `{}` when absent - [x] 3.2 In `app/src/workers/decisionTree.worker.ts`, extend `WorkerRequest` with `externalCredits: Record` and pass it through to `searchDecisionTree` ## 4. App state: external credits storage and cache invalidation - [x] 4.1 In `app/src/state/appState.ts`, extend `AppState` with `externalCredits: Record` - [x] 4.2 Add reducer action `{ type: 'setExternalCredit'; specId: string; credits: number }`. The reducer SHALL clamp negatives and `NaN` to `0`, treat `0` as deletion (omit the key from the new map), and produce an immutable update - [x] 4.3 Update `defaultState()` to include `externalCredits: {}`. Update `loadState()` to tolerate missing/invalid `externalCredits` (default to `{}`) - [x] 4.4 Extend the leaf-cache `LeafCache` type with `externalCredits: Record` and the cache-invalidation check (currently comparing `ranking` and `mode`) to also compare external credits via deterministic stringification of sorted non-zero entries - [x] 4.5 Extend `pinnedAssignments` and the worker `WorkerRequest` build to include `externalCredits`. Pass `externalCredits` into the main-thread `optimize(...)` call as well - [x] 4.6 Export a `setExternalCredit` callback from `useAppState` ## 5. UI: bar segment and breakdown - [x] 5.1 In `app/src/components/SpecializationRanking.tsx`, extend `CreditBar` props with `external: number` (default `0`) - [x] 5.2 Update `maxWidth` to `Math.max(potential + external, threshold)` - [x] 5.3 Render the amber stripe (`#f59e0b`) at the leftmost position with width `(external / maxWidth) * 100%`. The existing potential and allocated stripes shift to start at `external / maxWidth` (potential ends at `(external + potential) / maxWidth`; allocated ends at `(external + allocated) / maxWidth`). Tick marks and threshold marker positions remain expressed in absolute credits, scaled by `maxWidth` - [x] 5.4 Switch the allocated stripe color to green (`#22c55e`) when `(allocated + external) >= threshold`, blue (`#3b82f6`) otherwise - [x] 5.5 In `AllocationBreakdown`, accept `external: number` and prepend a `External` line item when `external > 0`. Use a small visual cue (italic label, amber accent text, or border) so it is identifiable without a legend - [x] 5.6 Pass `external` through from the spec card render path to both `CreditBar` and `AllocationBreakdown` ## 6. UI: inline editable credits chip - [x] 6.1 Add an `ExternalCreditChip` component (in `SpecializationRanking.tsx` or a new file under `app/src/components/`) that takes `value`, `onChange(next: number)`, and renders the display state (`+` or blank) and an inline numeric input on click/tap - [x] 6.2 Implement commit-on-blur and commit-on-Enter; clamp invalid input to `0`; allow decimals; reject negatives by clamping - [x] 6.3 Wire the chip into both the desktop chip layout and the mobile row layout in `SpecializationRanking.tsx`. Position so it does not displace drag handle, status badge, or rank number - [x] 6.4 Wire the chip's `onChange` to the new `setExternalCredit` callback from `useAppState` ## 7. Tests - [x] 7.1 In `app/src/solver/__tests__/feasibility.test.ts` (new or existing), add tests: - LP demand reduction: 4.0 external + 5.0 in-program-feasible → feasible; without the 4.0 external, same in-program selection is infeasible - External alone meets threshold: 9.0 external → spec is feasible with no in-program allocation - Empty external credits preserves prior behavior on a representative scenario - Upper-bound boost: spec with in-program potential 6.0 and external 5.0 yields bound 11.0 - `preFilterCandidates` includes spec with insufficient in-program potential when external closes the gap - [x] 7.2 In `app/src/solver/__tests__/optimizer.test.ts` (new or existing), add tests: - Hard 3-spec cap holds with and without external credits - External credits can substitute into the 3-spec set (e.g., HCR via 9 external) - `missing_required` survives external-only achievement: BRM with 9.0 external but no Brand Strategy stays `missing_required` and is not in the achieved set - `priorityOrder` respects the same external-credit math and the 3-spec cap - [x] 7.3 Add a state-layer test (or extend existing app-state tests) confirming the leaf cache is cleared when `externalCredits` changes and is retained when it does not - [x] 7.4 Run full suite; confirm all prior tests still pass ## 8. Browser verification - [ ] 8.1 Start dev server. Click the chip on a non-required-course spec (e.g., Banking), enter `2.5`. Bar updates with amber segment; achievement count and per-spec status update accordingly - [ ] 8.2 Increase external credits to `9.0`. Spec shows as `achieved` with the amber segment crossing the threshold tick. Allocation breakdown shows only the External line - [ ] 8.3 Click the chip on a required-course-gated spec (e.g., BRM) without selecting Brand Strategy, enter `9.0`. Spec stays in `missing_required`. Bar shows full amber but status badge is unchanged - [ ] 8.4 Combine: pin courses for an in-program 3-spec achievement, then add `9.0` external to a fourth spec. Verify the optimizer still reports 3 (cap holds) but the chosen 3 may shift to include the externally-credited spec - [ ] 8.5 Reload page. External credit values persist. Cache invalidation visible (next search re-runs) - [ ] 8.6 Confirm no console errors; verify the chip is reachable and editable on both desktop and mobile layouts ## 9. Version + changelog - [x] 9.1 Bump `__APP_VERSION__` and `__APP_VERSION_DATE__` in `app/vite.config.ts` to the next release (e.g., `1.4.1`) - [x] 9.2 Add a `CHANGELOG.md` entry: external credits per spec via inline chip, amber bar segment, achievement coloring on combined credit, lifted 3-spec ceiling, required-course gates unchanged