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.

The one file to change
src/config.js — swap the adapter import to fork for a new school
The hard seam
src/ports/ — JSDoc contracts that isolate institution logic from app logic
Core invariant
No file under src/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.

graph LR subgraph OUTSIDE["Outside — institution-specific"] NU["adapters/northeastern/\ninstitution.js\ncalendar.js\ncreditSystem.js\nattributeSystem.js\nspecialTerms.js\nmajorRequirements.js\ncourseCatalog.js\nlocalization.js"] GEN["adapters/generic/\n(fallback defaults)"] CFG["config.js\nwire(northeasternOverrides)"] end subgraph SEAM["The seam — port contracts"] P["ports/\nIInstitution\nICalendar\nICreditSystem\nIAttributeSystem\nISpecialTerms\nIMajorRequirements\nICourseCatalog\nILocalization"] end subgraph INSIDE["Inside — institution-agnostic"] IC["InstitutionContext\nusePort() / useInstitution()"] PC["PlannerContext\nall application state"] CORE["core/\nsemGrid · courseModel\nplanModel · prereqEval\nspecialTermUtils\ngradRequirements"] DATA["data/\ncourseLoader\nmajorLoader · minorLoader\npersistence"] UI["ui/\nHeader · BankPanel · GradPanel\nSemRow · SummerRow\nCourseCard · InfoPanel"] end NU -->|"implements"| P GEN -->|"implements"| P CFG -->|"wire(overrides)"| IC P -->|"usePort()"| IC IC --> PC IC --> UI PC --> CORE PC --> DATA UI --> PC style OUTSIDE fill:#ffe4e6,stroke:#e11d48 style SEAM fill:#fef3c7,stroke:#d97706 style INSIDE fill:#ddf4ff,stroke:#0969da
The ports layer (yellow) is the only boundary between institution-specific code (red) and the app core (blue). Nothing inside ever imports from 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/.

graph TD subgraph A["adapters/ + config.js"] CFG2["config.js"] W["wire.js"] NUA["northeastern/"] GENA["generic/"] end subgraph CTX["context/"] IC2["InstitutionContext.jsx\nexports: usePort(), useInstitution(),\nInstitutionProvider"] PC2["PlannerContext.jsx\nexports: usePlanner(), PlannerProvider"] TC["ThemeContext.jsx\nexports: useTheme(), ThemeProvider"] end subgraph CORE2["core/"] SG["semGrid.js"] CM["courseModel.js"] PM["planModel.js"] PE["prereqEval.js"] STU["specialTermUtils.js"] GR["gradRequirements.js"] CO["constants.js"] end subgraph DATA2["data/"] CL["courseLoader.js"] ML["majorLoader.js + minorLoader.js"] PS["persistence.js"] end subgraph UI2["ui/"] H["Header.jsx"] BP["BankPanel.jsx"] GP["GradPanel.jsx"] SR["SemRow.jsx + SummerRow.jsx"] CC["CourseCard.jsx"] IP["InfoPanel.jsx"] end CFG2 --> W NUA --> W GENA --> W W --> IC2 IC2 --> PC2 IC2 --> H IC2 --> BP IC2 --> GP IC2 --> SR PC2 --> H PC2 --> BP PC2 --> GP PC2 --> SR PC2 --> CC PC2 --> IP PC2 --> SG PC2 --> CM PC2 --> PM PC2 --> PE PC2 --> STU PC2 --> CL PC2 --> ML PC2 --> PS style A fill:#ffe4e6,stroke:#e11d48 style CTX fill:#fbefff,stroke:#8250df style CORE2 fill:#dafbe1,stroke:#1a7f37 style DATA2 fill:#fff8c5,stroke:#9a6700 style UI2 fill:#ddf4ff,stroke:#0969da
Imports go downward only. UI imports Context. Context imports Core and Data (passing ports as parameters, not importing adapters directly). Core and Data have zero adapter imports.

The institution injection chain

How a value defined in northeastern/calendar.js reaches a React component — traced end to end.

flowchart LR F1["northeastern/calendar.js\nexport default {\n semesterTypes: [...],\n defaultStartYear: 2026\n}"] F2["northeastern/index.js\nconst overrides = {\n calendar: nuCalendar, ...\n}"] F3["adapters/wire.js\nwire(overrides)\n→ { ...generic, ...overrides }"] F4["config.js\nexport const institutionAdapter\n= wire(northeasternOverrides)"] F5["App.jsx\n<InstitutionProvider\n adapter={institutionAdapter}>"] F6["InstitutionContext\nstores adapter bundle\nin React context"] F7["any component\nconst calendar = usePort(ICalendar)\ncalendar.semesterTypes → [...]"] F1 --> F2 --> F3 --> F4 --> F5 --> F6 --> F7
One value in one adapter file reaches every component in the tree. Changing northeastern/calendar.js re-derives semester slots, span maps, year labels, and grid rendering simultaneously.

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.

ComponentusePlanner() forusePort() / useInstitution() for
Header.jsxplacements, plan list, settingsuseInstitution() — full adapter for PDF export
BankPanel.jsxcourse bank, drag stateusePort(ISpecialTerms) — term type list for drag sources
GradPanel.jsxplacements, major/minor selectionusePort(IAttributeSystem, ISpecialTerms, IMajorRequirements)
SemRow.jsxplacements, sem data, violationsusePort(ISpecialTerms) — term types for grid rendering
SummerRow.jsxplacements, sem datausePort(ISpecialTerms) — term types for grid rendering
CourseCard.jsxcourse data, drag state, offeringsusePort(ICreditSystem, ICalendar)
InfoPanel.jsxselected course, edges, offered overridesusePort(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 functionInstitution 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:

Span rule: A special term spans into the next consecutive semester slot when termWeight > semSlotWeight. specialTermUtils.termSpans(termWeight, semSlotWeight) is the single site where this rule lives.
TermtermWeightSlotsemSlotWeightSpans?Why
6-month co-op2.0Fall / Spring1.0Yes2.0 > 1.0
4-month co-op1.0Fall / Spring1.0No1.0 = 1.0
4-month co-op1.0Summer A0.5Yes1.0 > 0.5
2-month internship0.5Summer A0.5No0.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.

#InvariantHow 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

FileLayerKnows about adapters?Role
src/config.jsYes — the only non-adapter file that doesSelects the active institution. The one file to change when forking.
src/adapters/wire.jsAdaptersYesMerges overrides onto generic defaults. Dev-mode unknown-key warning.
src/adapters/northeastern/AdaptersYes — is oneNU reference implementation: 8 files, 1 per port.
src/adapters/generic/AdaptersYes — is oneFallback defaults for every port. Safe for development; override for production.
src/ports/I*.jsPortsNo — JSDoc onlyContracts. No logic, no imports. Each exports one string key constant.
src/context/InstitutionContext.jsxContextNoInjects adapter bundle into React tree. usePort(key) reads one port.
src/context/PlannerContext.jsxContextNoAll application state. Reads 4 ports via usePort(). Passes config to core/data as parameters.
src/context/ThemeContext.jsxContextNoCSS variable injection from themes.js. Independent of institution.
src/core/semGrid.jsCoreNoSemester grid generation from cohort dates + semester type list (parameter).
src/core/courseModel.jsCoreNoNormalizes raw catalog JSON. Maps raw.nuPath ?? raw.attributescourse.attributes. getOfferedFromTerms(terms, decodeTermCode) accepts an optional ICalendar.decodeTermCode parameter; falls back to NU Banner convention when omitted.
src/core/specialTermUtils.jsCoreNoresolveTermByDuration, termSpans, computeGrantedAttrs. The bridge between adapter data and grid/coverage logic.
src/core/planModel.jsCoreNoSH totals, course ordering, PDF export. Receives full adapter bundle as parameter.
src/core/prereqEval.jsCoreNoPrerequisite tree evaluation. Fully structural — no institution data needed.
src/core/gradRequirements.jsCoreNoMajor2 requirement tree allocation. Pure structural matching.
src/core/constants.jsCoreNoGeneric UI constants: color palette, relation styles, NUM_YEARS.
src/data/courseLoader.jsDataNofetchCourses(courseCatalog) — URLs come in as a parameter.
src/data/majorLoader.jsDataNogetMajorOptions(majorRequirements) — path helpers come in as a parameter. Vite glob is a build-time literal.
src/data/persistence.jsDataNoloadSaved(prefix), saveState(state, prefix) — storage key namespace comes in as a parameter.

Adding a new institution — the four-step fork

  1. Copy the adapter folder: cp -r src/adapters/northeastern/ src/adapters/myuniversity/
  2. Edit adapter files: At minimum, institution.js (identity) and courseCatalog.js (data URL). Edit others as needed for your semester structure, gen-ed system, work terms, and disclaimers.
  3. Change config.js: Replace the import on line 2. That's the only non-adapter file that needs to change.
  4. Add course data: Place your all-courses.json at the path set in courseCatalog.js.

See adapters/generic/ for the full forking checklist and default behavior of each port.