Drag & Drop PlannerContext subsystem

Two parallel input systems — the browser's HTML5 Drag and Drop API (mouse) and a custom touch implementation — converge on the same four handlers. The central design constraint is that the touch system must work on iOS Safari, which has poor native DnD support.

Shared state
dragInfo — set by both systems, read by handlers and UI
Validation
canDropSem(semId) — called on every dragover to decide if a drop is legal

Overall pipeline

flowchart TD subgraph Mouse["Mouse — HTML5 DnD API"] MS["mousedown on draggable element\nonDragStart sets dragInfo"] MS --> MOV["ondragover fires on potential targets\ncanDropSem() → e.preventDefault() if valid"] MOV --> MDROP["ondrop fires\nreads dragInfo"] end subgraph Touch["Touch — custom document listener"] TS["touchstart on [data-drag-id]\nclone ghost, dim original, set dragInfo via refs"] TS --> TMOV["touchmove\nmove ghost, update hoveredSem via elementFromPoint"] TMOV --> TDROP["touchend\nelementFromPoint → find target\nbounding-rect fallback for placed-out zone"] end MDROP --> HANDLER TDROP --> HANDLER HANDLER{"Which target?"} HANDLER -->|"data-sem-id"| DROP["onDrop(semId)\nplace / move item\ncoreq partners move silently"] HANDLER -->|"data-drop-bank"| BANK["onDropBank()\ncourse → bank\nco-op/intern → deleted"] HANDLER -->|"data-drop-placedout"| PLOUT["onDropPlacedOut()\ncourse → placedOut set\nremoved from placements"] HANDLER -->|"card data-drag-id"| REORDER["onDropOnCard()\nreorder within semester\nswap in semOrders"] HANDLER -->|"no valid target"| CANCEL["setDragInfo(null)\ncancel"]
Both input systems set the same dragInfo and call the same handlers. The touch system uses refs (not React state) during the gesture to avoid re-renders.

dragInfo shape

{ id: string,        // item ID — null for new term templates dragged from the bank
  type: string,      // "course" | "specialTerm"
  typeId?: string,   // adapter term type — e.g. "coop" | "intern" — only for "specialTerm"
  fromSem: string,   // source semId (null if dragging from bank/template)
  duration?: number  // months — only present for "specialTerm"
}

Drop targets

TargetData attributeTypes acceptedEffect
Semester zonedata-sem-idcourse, specialTermonDrop(semId) — places or moves item. For courses: coreq partners move silently to the same semester.
Bank paneldata-drop-bankcourse, specialTermonDropBank() — removes placement. Special terms deleted entirely; course returns to bank.
Placed-out zonedata-drop-placedoutcourse onlyonDropPlacedOut() — adds to placedOut set, removes from placements.
Another course carddata-drag-id on targetcourse onlyonDropOnCard(targetId, semId) — reorders within the semester by swapping positions in semOrders.

Drop validation

canDropSem(semId) is called on every dragover event. If it returns false, e.preventDefault() is not called and the browser shows the "no drop" cursor. The same function runs again inside onDrop as a safety check before committing the mutation.

ItemDurationValid semester typesBlocked if
CourseAnySemester is occupied by a special term (start or continuation row)
Co-op (typeId: "coop")4-monthFall or spring (single row); summer (normalizes to sumA, spans sumA→sumB)Any other term in start or continuation slot
Co-op (typeId: "coop")6-monthSpring→sumA or sumB→Fall onlyAny other term in either of the two spanned semesters
Internship (typeId: "intern")2-monthSummer only (single row)Any other term already there
Internship (typeId: "intern")4-monthFall or spring (single); summer (normalizes to sumA, spans sumA→sumB)Any other term in start or continuation slot
Custom typeanyDetermined by weight-based span logicAny slot already occupied (isSlotOccupied)

Corequisite partner movement

When a course is dropped onto a semester or the bank, PlannerContext scans allEdges for all corequisite partners of the dragged course and moves them to the same destination. This is silent — no confirmation, no visual indicator. The partners are also removed from semOrders in their old semesters. This means dragging one course in a coreq pair always relocates both.

Touch drag pipeline

A single touchstart / touchmove / touchend listener is attached to document at mount. All drag state lives in refs (not React state) to avoid triggering re-renders during the gesture. INPUT, BUTTON, SELECT, and TEXTAREA elements receive native touch handling and do not trigger drag.

sequenceDiagram participant Finger participant Doc as document listener participant Ghost as Ghost element participant Context as PlannerContext Finger->>Doc: touchstart on [data-drag-id] Doc->>Ghost: cloneNode(true) — position:fixed, z-index:9999, opacity:0.92, scale(1.06) Doc->>Doc: dim original card (opacity 0.3, pointerEvents none) Doc->>Context: setDragInfo({id, type, fromSem, duration}) loop touchmove Finger->>Doc: touchmove Doc->>Ghost: reposition ghost to finger coordinates Doc->>Context: setHoveredSem via elementFromPoint end Finger->>Doc: touchend Doc->>Ghost: remove ghost element Doc->>Doc: restore original card opacity + pointerEvents Doc->>Doc: elementFromPoint(touchX, touchY) → find drop target note over Doc: priority order: data-drop-placedout (with bounding-rect fallback),
then data-drop-bank, then data-sem-id Doc->>Context: call onDropPlacedOut / onDropBank / onDrop
Ghost position is offset by the initial finger-to-card-corner distance so the card doesn't jump when the drag starts.

Placed-out bounding-rect fallback

target.closest('[data-drop-placedout]') can miss its target when the ghost element is positioned over it — elementFromPoint returns the ghost instead of the container below. The code handles this by iterating all [data-drop-placedout] elements and doing a manual getBoundingClientRect() hit test as a fallback when closest() returns null.