From f326382c76f9c1f0b58e7583b32189c0448594d0 Mon Sep 17 00:00:00 2001 From: AJaccP Date: Mon, 29 Jun 2026 15:12:55 -0400 Subject: [PATCH] add planner scaffold --- src/components/CoursePalette.tsx | 17 +++++ src/components/Slot.tsx | 90 +++++++++++++++++++++++++ src/components/TermCell.tsx | 86 ------------------------ src/components/TermColumn.tsx | 25 +++++++ src/components/TermGrid.tsx | 73 ++++++++++++++++++++ src/pages/Planner.tsx | 75 ++++++--------------- src/store/plannerStore.test.ts | 111 +++++++++++++++++++++++++++++++ src/store/plannerStore.ts | 73 +++++++++++++++----- src/types/planner.ts | 2 +- 9 files changed, 394 insertions(+), 158 deletions(-) create mode 100644 src/components/CoursePalette.tsx create mode 100644 src/components/Slot.tsx delete mode 100644 src/components/TermCell.tsx create mode 100644 src/components/TermColumn.tsx create mode 100644 src/components/TermGrid.tsx create mode 100644 src/store/plannerStore.test.ts diff --git a/src/components/CoursePalette.tsx b/src/components/CoursePalette.tsx new file mode 100644 index 0000000..c53dccb --- /dev/null +++ b/src/components/CoursePalette.tsx @@ -0,0 +1,17 @@ +// Reserved pane for the course palette. A later ticket fills this with course +// tiles (built from the same `courseList`/`courses` data the Explorer uses) that +// students drag onto term slots — course tiles are consumed once placed, blanket +// tiles (electives) are reusable. For now it's a labelled placeholder so the +// two-pane layout contract is locked and the palette ticket only fills the body. +// +// Hidden below md to keep narrow screens to the grid alone. +export default function CoursePalette() { + return ( + + ); +} diff --git a/src/components/Slot.tsx b/src/components/Slot.tsx new file mode 100644 index 0000000..900baeb --- /dev/null +++ b/src/components/Slot.tsx @@ -0,0 +1,90 @@ +import { useState, type SyntheticEvent } from 'react'; +import { courses } from '@/data/loadCourses'; +import { usePlannerStore } from '@/store/plannerStore'; +import type { PlannerEntry } from '@/types/planner'; + +interface Props { + termId: string; + index: number; + entry: PlannerEntry | null; +} + +function normalize(raw: string): string { + return raw.trim().toUpperCase().replace(/\s+/g, ' '); +} + +// One course slot box. For now it's a typeable text field holding a course code; +// a later ticket turns it into a drag-and-drop target. The slot's positional +// identity is (termId, index) — committing writes through the store's setSlot. +export default function Slot({ termId, index, entry }: Props) { + const setSlot = usePlannerStore((s) => s.setSlot); + + const filledCode = entry?.kind === 'course' ? entry.code : ''; + const [input, setInput] = useState(filledCode); + const [error, setError] = useState(null); + + // Re-sync the box when the committed code changes underneath us (e.g. a + // template load or future drag-drop replaces the slot). Adjusting state during + // render — React's recommended alternative to a syncing effect. + const [lastFilled, setLastFilled] = useState(filledCode); + if (filledCode !== lastFilled) { + setLastFilled(filledCode); + setInput(filledCode); + setError(null); + } + + // elective / choose entries aren't typeable yet (separate ticket). Render them + // read-only so a future template's placeholder can't be silently overwritten. + if (entry !== null && entry.kind !== 'course') { + const label = + entry.kind === 'elective' ? entry.category : entry.description; + return ( +
+ {label} +
+ ); + } + + function commit(e: SyntheticEvent) { + e.preventDefault(); + const code = normalize(input); + + if (code === '') { + if (filledCode !== '') setSlot(termId, index, null); + setError(null); + return; + } + + if (!courses.has(code)) { + setError('Unknown course code'); + return; + } + + setSlot(termId, index, { kind: 'course', code }); + setInput(code); + setError(null); + } + + return ( +
+ { + setInput(e.target.value); + setError(null); + }} + onBlur={commit} + placeholder="e.g. COMP 1405" + aria-label={`Course slot ${index + 1}`} + aria-invalid={error !== null} + className={`w-full rounded border px-2 py-1 text-xs ${ + error !== null ? 'border-red-500' : 'border-gray-300' + }`} + /> + {error !== null && ( +

{error}

+ )} +
+ ); +} diff --git a/src/components/TermCell.tsx b/src/components/TermCell.tsx deleted file mode 100644 index 11c109c..0000000 --- a/src/components/TermCell.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import { useState, type FormEvent } from 'react'; -import { courses } from '@/data/loadCourses'; -import { usePlannerStore } from '@/store/plannerStore'; -import type { PlannerEntry } from '@/types/planner'; - -interface Props { - termId: string; - label: string; - entries: PlannerEntry[]; -} - -function normalize(raw: string): string { - return raw.trim().toUpperCase().replace(/\s+/g, ' '); -} - -export default function TermCell({ termId, label, entries }: Props) { - const { addCourse, removeEntry } = usePlannerStore(); - const [input, setInput] = useState(''); - const [error, setError] = useState(null); - - function submit(e: FormEvent) { - e.preventDefault(); - const code = normalize(input); - - if (!courses.has(code)) { - setError('Unknown course code'); - return; - } - - addCourse(termId, code); - setInput(''); - setError(null); - } - - return ( -
-

{label}

- -
    - {entries.map((entry, index) => { - if (entry.kind !== 'course') { - // TODO: render non-course entry kinds (ticket: planner visual redesign) - return null; - } - return ( -
  • - {entry.code} - -
  • - ); - })} -
- -
-
- { - setInput(e.target.value); - setError(null); - }} - placeholder="e.g. COMP 1405" - className="flex-1 rounded border border-gray-300 px-2 py-1 text-xs" - /> - -
- {error !== null &&

{error}

} -
-
- ); -} diff --git a/src/components/TermColumn.tsx b/src/components/TermColumn.tsx new file mode 100644 index 0000000..502617c --- /dev/null +++ b/src/components/TermColumn.tsx @@ -0,0 +1,25 @@ +import type { Term } from '@/store/plannerStore'; +import Slot from './Slot'; + +interface Props { + term: Term; +} + +// One term's column of slot boxes. Renders off `term.slots` (never a global +// count) so a future "add slot" ticket that grows a single term's array works +// without touching this component. A later ticket makes this a DnD drop zone. +export default function TermColumn({ term }: Props) { + return ( +
+ {/* + Key on the slot's stable id, not its index: indices shift when a future + remove-slot / reorder ticket moves slots, and an index key would reattach + a box's local input/error state to the wrong row. setSlot is still + index-addressed — the id is for identity, the index for mutation. + */} + {term.slots.map((slot, index) => ( + + ))} +
+ ); +} diff --git a/src/components/TermGrid.tsx b/src/components/TermGrid.tsx new file mode 100644 index 0000000..6f9dfff --- /dev/null +++ b/src/components/TermGrid.tsx @@ -0,0 +1,73 @@ +import { seasonLabel, type Term } from '@/store/plannerStore'; +import TermColumn from './TermColumn'; + +interface Props { + terms: Term[]; +} + +const ORDINALS = ['First', 'Second', 'Third', 'Fourth', 'Fifth', 'Sixth']; + +function yearLabel(year: number): string { + const ordinal = ORDINALS[year - 1]; + return ordinal ? `${ordinal} Year` : `Year ${year}`; +} + +// Groups consecutive terms by year, preserving the earliest-first order. Each +// group drives one year header that spans its terms' columns. +function groupByYear(terms: Term[]): { year: number; terms: Term[] }[] { + const groups: { year: number; terms: Term[] }[] = []; + for (const term of terms) { + const last = groups[groups.length - 1]; + if (last && last.year === term.year) last.terms.push(term); + else groups.push({ year: term.year, terms: [term] }); + } + return groups; +} + +// Term-by-term grid: a year-header band on top, Fall/Winter sub-headers, then a +// column of slot boxes per term. Everything is derived from the `terms` array — +// no hardcoded term/year count. A single CSS grid (one track per term) keeps the +// year spans and the slot rows aligned across columns. +// +// TODO(ticket: planner legend) — a legend for connector-line / category-colour +// meaning belongs above this band once those features land. +export default function TermGrid({ terms }: Props) { + const yearGroups = groupByYear(terms); + + return ( +
+
+ {/* Row 1 — year headers, each spanning its terms' columns. */} + {yearGroups.map((group) => ( +
+ {yearLabel(group.year)} +
+ ))} + + {/* Row 2 — Fall/Winter (season) sub-headers, one per term. */} + {terms.map((term) => ( +
+ {seasonLabel(term.season)} +
+ ))} + + {/* Row 3 — one slot column per term. */} + {terms.map((term) => ( + + ))} +
+
+ ); +} diff --git a/src/pages/Planner.tsx b/src/pages/Planner.tsx index bfda4c9..89de3e5 100644 --- a/src/pages/Planner.tsx +++ b/src/pages/Planner.tsx @@ -1,65 +1,34 @@ // TODO (volunteer tickets): -// - Drag-and-drop courses between terms -// - Course palette sidebar with draggable course cards -// - Autocomplete on the course input -// - Co-op term special rendering (when COOP courses are added) -// - Configurable program length (not always 8 terms) -// - Polished violation rendering (cards, line numbers, jump-to) -// - Plan export/import as JSON -// - Share plan via URL hash -// - Reset plan button with confirm dialog -// - Render elective placeholder entries (with category label, italic, distinct border color) -// - Render choose-from-set entries (with credit count + description) -// - "Start from template" dropdown at the top of the planner; loads a ProgramTemplate via the store's loadTemplate action -// - Template freshness disclaimer banner shown when a template is loaded (uses validFor and lastReviewed fields) -// - First curated template content: BCS General (separate PR, content not engineering) -// - Subsequent templates: BCS Honours, BCS SE Stream, BCS AI Stream, BMath Data Science, Cybersecurity minor -// - In-planner course detail panel (shared component with Explorer) -// - In-planner prereq highlighting (click a course, highlight its prereqs in earlier terms and unlocks in later terms) +// - Course palette panel + drag-and-drop course tiles onto slots (see CoursePalette) +// - Prereq validation: re-add the violation banner (validatePlan + ViolationList +// still exist; map each term's slots → compact entries, dropping nulls) +// - "Start from template" dropdown (store's loadTemplate) + freshness disclaimer +// - Render elective / choose entry kinds as styled tiles +// - "Add slot" / drag-overflow to grow a term up to MAX_SLOTS_PER_TERM +// - Add / remove terms (summer, year 5+); co-op term rendering +// - Prereq connector lines between slots; in-planner course detail panel +// - Plan export/import as JSON; share via URL hash; reset-plan button -import { useMemo } from 'react'; -import { usePlannerStore, termLabel } from '@/store/plannerStore'; -import { courses } from '@/data/loadCourses'; -import { validatePlan } from '@/lib/validatePlan'; -import TermCell from '@/components/TermCell'; -import ViolationList from '@/components/ViolationList'; +import { usePlannerStore } from '@/store/plannerStore'; +import CoursePalette from '@/components/CoursePalette'; +import TermGrid from '@/components/TermGrid'; export default function Planner() { const terms = usePlannerStore((s) => s.terms); - const violations = useMemo( - () => - validatePlan( - terms.map((t) => ({ - termId: t.id, - label: termLabel(t), - entries: t.entries, - })), - courses, - ), - [terms], - ); - return ( -
- +
+ -

- This tool validates prerequisite ordering only. It does not check - whether courses are offered in specific terms. Verify with the - registrar. -

+
+

+ This tool validates prerequisite ordering only. It does not check + whether courses are offered in specific terms. Verify with the + registrar. +

-
- {terms.map((term) => ( - - ))} -
+ +
); } diff --git a/src/store/plannerStore.test.ts b/src/store/plannerStore.test.ts new file mode 100644 index 0000000..c02ead5 --- /dev/null +++ b/src/store/plannerStore.test.ts @@ -0,0 +1,111 @@ +import { beforeEach, describe, it, expect } from 'vitest'; +import { + usePlannerStore, + termLabel, + seasonLabel, + SLOTS_PER_TERM, +} from './plannerStore'; +import type { ProgramTemplate } from '@/types/planner'; + +function freshStore() { + // Reset to the default plan before each test (the persisted state is shared). + usePlannerStore.persist.clearStorage(); + usePlannerStore.setState(usePlannerStore.getInitialState()); +} + +beforeEach(freshStore); + +describe('plannerStore', () => { + it('seeds each default term with SLOTS_PER_TERM empty slots, each with a unique id', () => { + const terms = usePlannerStore.getState().terms; + expect(terms.length).toBeGreaterThan(0); + const allIds = new Set(); + for (const term of terms) { + expect(term.slots).toHaveLength(SLOTS_PER_TERM); + expect(term.slots.every((s) => s.entry === null)).toBe(true); + for (const slot of term.slots) { + expect(typeof slot.id).toBe('string'); + allIds.add(slot.id); + } + } + // Ids are unique across every slot in the whole plan. + expect(allIds.size).toBe(terms.length * SLOTS_PER_TERM); + }); + + it('setSlot writes an entry at a positional (termId, index) without disturbing siblings or ids', () => { + const { id } = usePlannerStore.getState().terms[0]; + const idsBefore = usePlannerStore + .getState() + .terms[0].slots.map((s) => s.id); + + usePlannerStore + .getState() + .setSlot(id, 2, { kind: 'course', code: 'COMP 1405' }); + + const term = usePlannerStore.getState().terms.find((t) => t.id === id)!; + expect(term.slots[2].entry).toEqual({ kind: 'course', code: 'COMP 1405' }); + expect(term.slots[0].entry).toBeNull(); + expect(term.slots[1].entry).toBeNull(); + expect(term.slots).toHaveLength(SLOTS_PER_TERM); + // The slot keeps its stable id across a mutation — only `entry` changes. + expect(term.slots.map((s) => s.id)).toEqual(idsBefore); + }); + + it('setSlot with null clears a filled slot', () => { + const { id } = usePlannerStore.getState().terms[0]; + usePlannerStore + .getState() + .setSlot(id, 0, { kind: 'course', code: 'COMP 1405' }); + usePlannerStore.getState().setSlot(id, 0, null); + expect(usePlannerStore.getState().terms[0].slots[0].entry).toBeNull(); + }); + + it('setSlot is a no-op for an out-of-range index', () => { + const { id, slots } = usePlannerStore.getState().terms[0]; + usePlannerStore + .getState() + .setSlot(id, 99, { kind: 'course', code: 'COMP 1405' }); + expect(usePlannerStore.getState().terms[0].slots).toEqual(slots); + }); + + it('loadTemplate places authored entries earliest-first and pads to SLOTS_PER_TERM', () => { + const template: ProgramTemplate = { + id: 't', + name: 'Test', + description: '', + validFor: '', + lastReviewed: '', + reviewer: '', + terms: [ + { + year: 1, + season: 'fall', + entries: [ + { kind: 'course', code: 'COMP 1405' }, + { kind: 'elective', category: 'Breadth Elective' }, + ], + }, + ], + }; + usePlannerStore.getState().loadTemplate(template); + + const terms = usePlannerStore.getState().terms; + expect(terms).toHaveLength(1); + expect(terms[0].slots).toHaveLength(SLOTS_PER_TERM); + expect(terms[0].slots[0].entry).toEqual({ + kind: 'course', + code: 'COMP 1405', + }); + expect(terms[0].slots[1].entry).toEqual({ + kind: 'elective', + category: 'Breadth Elective', + }); + expect(terms[0].slots[2].entry).toBeNull(); + }); + + it('derives display labels from structured year/season', () => { + const term = usePlannerStore.getState().terms[0]; + expect(termLabel(term)).toBe('Year 1 Fall'); + expect(seasonLabel('winter')).toBe('Winter'); + }); +}); diff --git a/src/store/plannerStore.ts b/src/store/plannerStore.ts index e888387..8ec37d9 100644 --- a/src/store/plannerStore.ts +++ b/src/store/plannerStore.ts @@ -2,17 +2,43 @@ import { create } from 'zustand'; import { persist, createJSONStorage } from 'zustand/middleware'; import type { PlannerEntry, ProgramTemplate, Season } from '@/types/planner'; +// One box in a term column. `entry` is its contents (null = empty). `id` is a +// STABLE identity that survives the slot shifting position within its term — it's +// what React keys on, so a box's local UI state can't reattach to the wrong row +// when a future remove-slot / reorder ticket moves slots around. Mutations still +// address slots positionally (see setSlot); the id is for identity, not lookup. +export interface Slot { + id: string; + entry: PlannerEntry | null; +} + +// A term holds a FIXED-LENGTH array of slots, one per visual box in the column. +// Positional identity — (termId, slotIndex) — is the stable contract every +// slot-targeting feature builds on: text entry today, drag-and-drop / +// connector-line anchors later. export interface Term { id: string; year: number; season: Season; - entries: PlannerEntry[]; + slots: Slot[]; } +// Default boxes seeded per term column. This is only the SEED for makeTerm — the +// real length lives per-term in `term.slots`, so a future "add slot" ticket grows +// a single term's array (up to MAX_SLOTS_PER_TERM) without touching this contract. +// Render off `term.slots.length`, never off this constant. +export const SLOTS_PER_TERM = 5; + +// Documented ceiling for a term's slot count (the future grow/add-slot action +// caps here). Unused by the seed today; lives here so the limit has one home. +export const MAX_SLOTS_PER_TERM = 7; + interface PlannerState { terms: Term[]; - addCourse: (termId: string, code: string) => void; - removeEntry: (termId: string, index: number) => void; + // The single slot mutation. Text entry, drag-and-drop, and template loading + // all funnel through this: set a course/elective/choose entry at a slot, or + // pass `null` to clear it. + setSlot: (termId: string, index: number, entry: PlannerEntry | null) => void; loadTemplate: (template: ProgramTemplate) => void; } @@ -22,6 +48,11 @@ const SEASON_LABELS: Record = { summer: 'Summer', }; +// Just the season portion of a term's label, for the Fall/Winter sub-headers. +export function seasonLabel(season: Season): string { + return SEASON_LABELS[season]; +} + // Term identity is (year, season) — one term per season per year — so the id is // derived from those: stable, unique, and readable (e.g. "y1f", "y2w", "y3s"). function termId(year: number, season: Season): string { @@ -33,12 +64,24 @@ export function termLabel(term: Term): string { return `Year ${term.year} ${SEASON_LABELS[term.season]}`; } +// Each slot gets a process-unique id. randomUUID (not a counter) so ids never +// collide across remove-then-add or across a persisted reload, where a counter +// would reset and reissue an id already saved on disk. +function makeSlot(entry: PlannerEntry | null = null): Slot { + return { id: crypto.randomUUID(), entry }; +} + +// Builds a term whose slot array is padded to SLOTS_PER_TERM. Authored entries +// (templates) are placed earliest-first; remaining slots are empty. A term with +// more authored entries than SLOTS_PER_TERM grows to fit them all. function makeTerm( year: number, season: Season, entries: PlannerEntry[] = [], ): Term { - return { id: termId(year, season), year, season, entries }; + const length = Math.max(SLOTS_PER_TERM, entries.length); + const slots = Array.from({ length }, (_, i) => makeSlot(entries[i] ?? null)); + return { id: termId(year, season), year, season, slots }; } const DEFAULT_TERMS: Term[] = [ @@ -57,22 +100,16 @@ export const usePlannerStore = create()( (set, get) => ({ terms: DEFAULT_TERMS, - addCourse: (termId, code) => { - set({ - terms: get().terms.map((t) => - t.id === termId && - !t.entries.some((e) => e.kind === 'course' && e.code === code) - ? { ...t, entries: [...t.entries, { kind: 'course', code }] } - : t, - ), - }); - }, - - removeEntry: (termId, index) => { + setSlot: (termId, index, entry) => { set({ terms: get().terms.map((t) => t.id === termId - ? { ...t, entries: t.entries.filter((_, i) => i !== index) } + ? { + ...t, + slots: t.slots.map((s, i) => + i === index ? { ...s, entry } : s, + ), + } : t, ), }); @@ -87,7 +124,7 @@ export const usePlannerStore = create()( }, }), { - name: 'course-graph-planner-v3', + name: 'course-graph-planner-v5', storage: createJSONStorage(() => localStorage), partialize: (state) => ({ terms: state.terms }), }, diff --git a/src/types/planner.ts b/src/types/planner.ts index 259d614..adb28e7 100644 --- a/src/types/planner.ts +++ b/src/types/planner.ts @@ -1,6 +1,6 @@ export type PlannerEntry = | { kind: 'course'; code: string } - | { kind: 'elective'; category: string } // e.g. "Breadth Elective", "CS Elective" + | { kind: 'elective'; category: string } // e.g. "Breadth Elective", "Free Elective" | { kind: 'choose'; credits: number; description: string }; // e.g. "0.5 credit from MATH 2000-level or above" export type Season = 'fall' | 'winter' | 'summer';