Plans & Persistence PlannerContext subsystem

A user can maintain multiple independent plans. Each plan is a named slot in localStorage. PlannerContext manages the plan index, active plan pointer, switching, and auto-save. The persistence layer itself (persistence.js) only handles serialization — all logic for when to save lives here.

Persistence module
persistence.js — loadSaved, saveState, clearState
Undo/redo scope
Per-plan — undo/redo stacks are cleared on switchPlan

Multi-plan storage layout

Three localStorage layers hold the multi-plan state. The index and active pointer are small; full plan data (potentially large) lives in per-plan keys.

flowchart LR subgraph LS["localStorage"] IDX["ncp-plan-index\n[{id, name}, ...]"] ACT["ncp-active-plan\nplanId string"] D1["ncp-plan-data-plan_1\nfull plan JSON"] D2["ncp-plan-data-plan_2\nfull plan JSON"] end PC["PlannerContext"] PC -->|"write — plans[] changed"| IDX PC -->|"write — activePlanId changed"| ACT PC -->|"write — any state change\n+ immediately on switchPlan"| D1 PC -->|"write — any state change\n+ immediately on switchPlan"| D2 IDX -->|"read on mount"| PC ACT -->|"read on mount → which plan to load"| PC D1 -->|"read when activePlanId changes"| PC D2 -->|"read when activePlanId changes"| PC
Each plan gets its own ncp-plan-data-{id} key. Switching plans triggers an immediate non-debounced save of the current plan before loading the new one.

switchPlan sequence

The critical invariant is that the current plan is always saved before the switch, so no edits are lost if the browser crashes or the tab is closed mid-switch.

sequenceDiagram participant U as User participant H as Header participant PC as PlannerContext participant LS as localStorage U->>H: click plan name H->>PC: switchPlan(newId) PC->>LS: write ncp-plan-data-{currentId} immediately note over PC,LS: save-before-switch — prevents data loss on crash PC->>PC: setActivePlanId(newId) note over PC: useEffect([activePlanId]) fires async PC->>LS: read ncp-plan-data-{newId} alt plan data found LS-->>PC: plan JSON PC->>PC: restorePlan(d) — hydrates all React state PC->>LS: write ncp-ent/grad sem/year (cohort follows active plan) else no data (brand-new plan) PC->>PC: resetPlanToDefaults() end PC->>PC: reset bankSearch / bankTab / bankSort
The save-before-switch is synchronous and immediate — not the debounced auto-save. The undo/redo stacks are also cleared at this point so Cmd+Z does not reach across plans.

What captureCurrentPlan() serializes

Every call to captureCurrentPlan() snapshots the current React state into a plain object. This is identical to the export JSON — File → Export and the auto-save write the same structure.

{
  placements,      // { courseId → semId }
  specialTermPl,   // { termId → { typeId, semId, duration, company, companyDomain, subline } }
  semOrders,       // { semId → courseId[] }
  shOverrides,     // { courseId → number }
  bonusSH,         // { semId → number }
  currentSemId,    // string — real-world current semester
  offeredOverrides,// manual offered-status overrides
  collapsedSubs,   // collapsed substitution groups
  major, conc,     // graduation panel selections
  minor1, minor2,
  placedOut        // string[] — serialized from Set
}

Cohort settings and plan switching

ncp-ent-sem/year and ncp-grad-sem/year are stored as global localStorage keys, but restorePlan() also writes them when loading a plan. This means the "global" cohort settings always reflect the most recently active plan — switching plans updates the cohort to that plan's entry/graduation dates.

Persistence auto-save

A useEffect watching all persisted state calls saveCurrentPlanToSlot() on every change, skipping the first render via an isFirstRender ref. The call is debounced to avoid hammering localStorage on rapid state changes (e.g., drag reordering). The plan object written to localStorage matches the export format exactly.

localStorage key reference

// Written by PlannerContext on plan changes
ncp-plan-index        // [{id, name}] — plan list
ncp-active-plan       // currently loaded plan ID
ncp-plan-data-{id}    // full plan JSON per plan

// Cohort — reflect most-recently-active plan
ncp-ent-sem/year      // entry semester and year
ncp-grad-sem/year     // graduation semester and year

// Global — not cleared on plan reset
ncp-starred           // starred course IDs (global, not per-plan)
ncp-sticky-courses    // boolean — courses stay in semester on cohort shift
ncp-zoom              // manual zoom level
ncp-theme             // theme name string
ncp-collapse-other-credits // boolean (default true) — collapse ≤2 SH section
ncp-show-cont-logo    // boolean (default true) — logo on co-op/intern continuation rows

See persistence.js for the full key reference including non-plan keys.