+ {/*
+ 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';