3a5ebaa17a
Students can now record credits earned in courses taken outside the J27 program via an inline editable amber chip on each spec card. Values flow through the LP (per-spec demand reduces by external amount), upper-bound math, decision-tree search, and the credit bar visualization. The 9-credit threshold and the 3-spec achievement cap are unchanged; required-course gates remain authoritative — external credits never satisfy them.
7.9 KiB
7.9 KiB
1. Solver: external-credit-aware feasibility and bounds
- 1.1 In
app/src/solver/feasibility.ts, extendcheckFeasibilityto acceptexternalCredits?: Record<string, number>. Before building per-spec demand constraints, computeadjusted = max(0, CREDIT_THRESHOLD - (externalCredits[specId] ?? 0)). Ifadjusted === 0, omit both theneed_<spec>constraint and anyx_<course>_<spec>variables for that spec entirely. Otherwise setneed_<spec> = { min: adjusted } - 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 letoptimizer.determineStatusesmark them asachievedbased on the achieved-set rather than allocation totals. Document this in a one-line comment infeasibility.ts - 1.3 Extend
computeUpperBoundsto acceptexternalCredits?: Record<string, number>and add(externalCredits[spec.id] ?? 0)to each spec's potential before storing the bound - 1.4 Extend
preFilterCandidatesto acceptexternalCredits?: Record<string, number>and use the boosted potential when comparing againstCREDIT_THRESHOLD
2. Solver: optimizer ceiling and status
- 2.1 In
app/src/solver/optimizer.ts, threadexternalCredits?: Record<string, number>throughmaximizeCount,priorityOrder,determineStatuses,optimize, andcheckWithS2. All call sites pass through (workers, app state) - 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) - 2.3 In
determineStatuses, ensuremissing_requiredprecedence is preserved: whenspec.requiredCourseIdis unsatisfied, status remainsmissing_requiredregardless ofexternalCredits[spec.id] - 2.4 Keep
priorityOrder'sif (achieved.length >= 3) break;guard alongside the maxSize cap, mirroringmaximizeCount
3. Solver: decision tree + worker plumbing
- 3.1 In
app/src/solver/decisionTree.ts, threadexternalCredits?: Record<string, number>intosearchDecisionTree,evaluateLeaf,reorderForTarget,reorderByReachableQualCount, and any helper that callscomputeUpperBounds/optimize/checkFeasibility. Default to{}when absent - 3.2 In
app/src/workers/decisionTree.worker.ts, extendWorkerRequestwithexternalCredits: Record<string, number>and pass it through tosearchDecisionTree
4. App state: external credits storage and cache invalidation
- 4.1 In
app/src/state/appState.ts, extendAppStatewithexternalCredits: Record<string, number> - 4.2 Add reducer action
{ type: 'setExternalCredit'; specId: string; credits: number }. The reducer SHALL clamp negatives andNaNto0, treat0as deletion (omit the key from the new map), and produce an immutable update - 4.3 Update
defaultState()to includeexternalCredits: {}. UpdateloadState()to tolerate missing/invalidexternalCredits(default to{}) - 4.4 Extend the leaf-cache
LeafCachetype withexternalCredits: Record<string, number>and the cache-invalidation check (currently comparingrankingandmode) to also compare external credits via deterministic stringification of sorted non-zero entries - 4.5 Extend
pinnedAssignmentsand the workerWorkerRequestbuild to includeexternalCredits. PassexternalCreditsinto the main-threadoptimize(...)call as well - 4.6 Export a
setExternalCreditcallback fromuseAppState
5. UI: bar segment and breakdown
- 5.1 In
app/src/components/SpecializationRanking.tsx, extendCreditBarprops withexternal: number(default0) - 5.2 Update
maxWidthtoMath.max(potential + external, threshold) - 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 atexternal / 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 bymaxWidth - 5.4 Switch the allocated stripe color to green (
#22c55e) when(allocated + external) >= threshold, blue (#3b82f6) otherwise - 5.5 In
AllocationBreakdown, acceptexternal: numberand prepend aExternalline item whenexternal > 0. Use a small visual cue (italic label, amber accent text, or border) so it is identifiable without a legend - 5.6 Pass
externalthrough from the spec card render path to bothCreditBarandAllocationBreakdown
6. UI: inline editable credits chip
- 6.1 Add an
ExternalCreditChipcomponent (inSpecializationRanking.tsxor a new file underapp/src/components/) that takesvalue,onChange(next: number), and renders the display state (+<value>or blank) and an inline numeric input on click/tap - 6.2 Implement commit-on-blur and commit-on-Enter; clamp invalid input to
0; allow decimals; reject negatives by clamping - 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 - 6.4 Wire the chip's
onChangeto the newsetExternalCreditcallback fromuseAppState
7. Tests
- 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
preFilterCandidatesincludes spec with insufficient in-program potential when external closes the gap
- 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_requiredsurvives external-only achievement: BRM with 9.0 external but no Brand Strategy staysmissing_requiredand is not in the achieved setpriorityOrderrespects the same external-credit math and the 3-spec cap
- 7.3 Add a state-layer test (or extend existing app-state tests) confirming the leaf cache is cleared when
externalCreditschanges and is retained when it does not - 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 asachievedwith 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 inmissing_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.0external 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
- 9.1 Bump
__APP_VERSION__and__APP_VERSION_DATE__inapp/vite.config.tsto the next release (e.g.,1.4.1) - 9.2 Add a
CHANGELOG.mdentry: external credits per spec via inline chip, amber bar segment, achievement coloring on combined credit, lifted 3-spec ceiling, required-course gates unchanged