Architecture Hexagonal
A single import in config.js selects the active institution. Everything else — semester structure, gen-ed grid, co-op rules, course catalog URL, disclaimers — flows from that one file through a chain of contracts (ports), implementations (adapters), a context bridge (InstitutionContext), and out to every component via usePort(). The app core has zero institution-specific imports.
src/config.js — swap the adapter import to fork for a new schoolsrc/ports/ — JSDoc contracts that isolate institution logic from app logicsrc/core/ or src/data/ may import from src/adapters/The hexagonal boundary
The fundamental split: everything inside the hexagon is institution-agnostic; everything outside is institution-specific. The port contracts are the seam — they define exactly what the inside needs from the outside.
Full layer dependency rules
Six layers. Arrows point in the direction of imports. No arrow ever points from a lower layer to a higher one, and no layer inside the hexagon imports from adapters/.
The institution injection chain
How a value defined in northeastern/calendar.js reaches a React component — traced end to end.
How each layer uses institution config
PlannerContext — reads ports directly
PlannerContext is the largest consumer. It calls usePort() for the four ports it needs at startup, then passes sub-values down as parameters to core and data functions.
// src/context/PlannerContext.jsx
const institution = usePort(IInstitution); // storagePrefix for localStorage keys
const calendar = usePort(ICalendar); // semester types → semGrid, year labels
const courseCatalog = usePort(ICourseCatalog); // URLs → fetchCourses(courseCatalog)
const specialTerms = usePort(ISpecialTerms); // types → span maps, drop validation
// Institution config is passed as explicit parameters — never imported inside the functions
fetchCourses(courseCatalog);
loadSaved(institution.storagePrefix);
exportReport(state, adapter); // full adapter bundle for PDF export
UI components — split between usePlanner() and usePort()
Most state comes from usePlanner(). Components call usePort() directly only for the ports they need that PlannerContext doesn't re-expose.
| Component | usePlanner() for | usePort() / useInstitution() for |
|---|---|---|
| Header.jsx | placements, plan list, settings | useInstitution() — full adapter for PDF export |
| BankPanel.jsx | course bank, drag state | usePort(ISpecialTerms) — term type list for drag sources |
| GradPanel.jsx | placements, major/minor selection | usePort(IAttributeSystem, ISpecialTerms, IMajorRequirements) |
| SemRow.jsx | placements, sem data, violations | usePort(ISpecialTerms) — term types for grid rendering |
| SummerRow.jsx | placements, sem data | usePort(ISpecialTerms) — term types for grid rendering |
| CourseCard.jsx | course data, drag state, offerings | usePort(ICreditSystem, ICalendar) |
| InfoPanel.jsx | selected course, edges, offered overrides | usePort(IAttributeSystem, ICreditSystem, ICalendar, ICourseCatalog) |
Core modules — institution config as parameters only
No file under src/core/ imports from src/adapters/. When a core function needs institution config, the caller passes it in explicitly. This is the invariant that makes the core testable without any adapter setup.
| Core function | Institution config received as |
|---|---|
buildCohortSemesters(startYear, numYears, semTypes) | semTypes from calendar.semesterTypes |
resolveTermByDuration(durations, duration) | durations from specialTerms.types[n].durations |
termSpans(termWeight, semSlotWeight) | both weights from adapter data, passed by PlannerContext |
computeGrantedAttrs(specialTermPl, types) | types from specialTerms.types |
exportReport(state, adapter) | full adapter bundle — destructures what it needs |
Weight-based span arithmetic
The single most important cross-cutting mechanic. Both ICalendar semester types and ISpecialTerms duration options carry a weight on the same numeric scale. One rule replaces all institution-specific span tables:
termWeight > semSlotWeight. specialTermUtils.termSpans(termWeight, semSlotWeight) is the single site where this rule lives.| Term | termWeight | Slot | semSlotWeight | Spans? | Why |
|---|---|---|---|---|---|
| 6-month co-op | 2.0 | Fall / Spring | 1.0 | Yes | 2.0 > 1.0 |
| 4-month co-op | 1.0 | Fall / Spring | 1.0 | No | 1.0 = 1.0 |
| 4-month co-op | 1.0 | Summer A | 0.5 | Yes | 1.0 > 0.5 |
| 2-month internship | 0.5 | Summer A | 0.5 | No | 0.5 = 0.5 |
This arithmetic runs identically for any institution's terms — no if (duration === 6) checks anywhere in core logic.
The three architectural invariants
These rules must hold. Violating any one breaks the ability to fork for a new institution without touching core logic.
| # | Invariant | How to verify |
|---|---|---|
| 1 | core/ has zero adapter imports. No file under src/core/ may import from src/adapters/**. |
grep -r "from.*adapters" src/core/ → zero results |
| 2 | Non-React functions receive ports as parameters. planModel, courseLoader, majorLoader, persistence cannot call usePort() — they receive whatever sub-ports they need as explicit arguments from their callers. |
grep -r "usePort\|useInstitution" src/core/ src/data/ → zero results |
| 3 | UI components get institution config only through usePort(). No component imports from src/adapters/** directly. |
grep -r "from.*adapters" src/ui/ → zero results |
File map — every file and its role
| File | Layer | Knows about adapters? | Role |
|---|---|---|---|
src/config.js | — | Yes — the only non-adapter file that does | Selects the active institution. The one file to change when forking. |
src/adapters/wire.js | Adapters | Yes | Merges overrides onto generic defaults. Dev-mode unknown-key warning. |
src/adapters/northeastern/ | Adapters | Yes — is one | NU reference implementation: 8 files, 1 per port. |
src/adapters/generic/ | Adapters | Yes — is one | Fallback defaults for every port. Safe for development; override for production. |
src/ports/I*.js | Ports | No — JSDoc only | Contracts. No logic, no imports. Each exports one string key constant. |
src/context/InstitutionContext.jsx | Context | No | Injects adapter bundle into React tree. usePort(key) reads one port. |
src/context/PlannerContext.jsx | Context | No | All application state. Reads 4 ports via usePort(). Passes config to core/data as parameters. |
src/context/ThemeContext.jsx | Context | No | CSS variable injection from themes.js. Independent of institution. |
src/core/semGrid.js | Core | No | Semester grid generation from cohort dates + semester type list (parameter). |
src/core/courseModel.js | Core | No | Normalizes raw catalog JSON. Maps raw.nuPath ?? raw.attributes → course.attributes. getOfferedFromTerms(terms, decodeTermCode) accepts an optional ICalendar.decodeTermCode parameter; falls back to NU Banner convention when omitted. |
src/core/specialTermUtils.js | Core | No | resolveTermByDuration, termSpans, computeGrantedAttrs. The bridge between adapter data and grid/coverage logic. |
src/core/planModel.js | Core | No | SH totals, course ordering, PDF export. Receives full adapter bundle as parameter. |
src/core/prereqEval.js | Core | No | Prerequisite tree evaluation. Fully structural — no institution data needed. |
src/core/gradRequirements.js | Core | No | Major2 requirement tree allocation. Pure structural matching. |
src/core/constants.js | Core | No | Generic UI constants: color palette, relation styles, NUM_YEARS. |
src/data/courseLoader.js | Data | No | fetchCourses(courseCatalog) — URLs come in as a parameter. |
src/data/majorLoader.js | Data | No | getMajorOptions(majorRequirements) — path helpers come in as a parameter. Vite glob is a build-time literal. |
src/data/persistence.js | Data | No | loadSaved(prefix), saveState(state, prefix) — storage key namespace comes in as a parameter. |
Adding a new institution — the four-step fork
- Copy the adapter folder:
cp -r src/adapters/northeastern/ src/adapters/myuniversity/ - Edit adapter files: At minimum,
institution.js(identity) andcourseCatalog.js(data URL). Edit others as needed for your semester structure, gen-ed system, work terms, and disclaimers. - Change config.js: Replace the import on line 2. That's the only non-adapter file that needs to change.
- Add course data: Place your
all-courses.jsonat the path set incourseCatalog.js.
See adapters/generic/ for the full forking checklist and default behavior of each port.