Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions src/components/CoursePalette.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<aside className="hidden w-56 shrink-0 flex-col border-r border-gray-200 p-4 md:flex">
<h2 className="text-sm font-semibold text-gray-800">Courses</h2>
<p className="mt-2 text-xs text-gray-400">
Drag-and-drop course palette — coming soon.
</p>
</aside>
);
}
90 changes: 90 additions & 0 deletions src/components/Slot.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(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 (
<div className="flex h-12 items-center rounded border border-dashed border-gray-300 bg-gray-50 px-2 text-xs text-gray-500 italic">
{label}
</div>
);
}

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 (
<form onSubmit={commit} className="flex h-12 flex-col justify-center">
<input
type="text"
value={input}
onChange={(e) => {
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 && (
<p className="px-1 text-[10px] text-red-600">{error}</p>
)}
</form>
);
}
86 changes: 0 additions & 86 deletions src/components/TermCell.tsx

This file was deleted.

25 changes: 25 additions & 0 deletions src/components/TermColumn.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex flex-col gap-2">
{/*
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) => (
<Slot key={slot.id} termId={term.id} index={index} entry={slot.entry} />
))}
</div>
);
}
73 changes: 73 additions & 0 deletions src/components/TermGrid.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="overflow-x-auto">
<div
className="grid min-w-full gap-x-3 gap-y-2"
style={{
gridTemplateColumns: `repeat(${terms.length}, minmax(11rem, 1fr))`,
}}
>
{/* Row 1 — year headers, each spanning its terms' columns. */}
{yearGroups.map((group) => (
<div
key={group.year}
className="rounded bg-gray-100 py-1 text-center text-xs font-semibold tracking-wide text-gray-700 uppercase"
style={{ gridColumn: `span ${group.terms.length}` }}
>
{yearLabel(group.year)}
</div>
))}

{/* Row 2 — Fall/Winter (season) sub-headers, one per term. */}
{terms.map((term) => (
<div
key={term.id}
className="text-center text-xs font-medium text-gray-500"
>
{seasonLabel(term.season)}
</div>
))}

{/* Row 3 — one slot column per term. */}
{terms.map((term) => (
<TermColumn key={term.id} term={term} />
))}
</div>
</div>
);
}
75 changes: 22 additions & 53 deletions src/pages/Planner.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex h-full flex-col gap-4 overflow-y-auto p-4">
<ViolationList violations={violations} />
<div className="flex h-full overflow-hidden">
<CoursePalette />

<p className="rounded bg-yellow-100 px-3 py-2 text-sm text-yellow-800">
This tool validates prerequisite ordering only. It does not check
whether courses are offered in specific terms. Verify with the
registrar.
</p>
<main className="flex flex-1 flex-col gap-4 overflow-auto p-4">
<p className="rounded bg-yellow-100 px-3 py-2 text-sm text-yellow-800">
This tool validates prerequisite ordering only. It does not check
whether courses are offered in specific terms. Verify with the
registrar.
</p>

<div className="grid grid-cols-2 gap-3">
{terms.map((term) => (
<TermCell
key={term.id}
termId={term.id}
label={termLabel(term)}
entries={term.entries}
/>
))}
</div>
<TermGrid terms={terms} />
</main>
</div>
);
}
Loading
Loading