Skip to content

Commit eb12d82

Browse files
authored
add planner scaffold (#26)
1 parent 309671e commit eb12d82

9 files changed

Lines changed: 394 additions & 158 deletions

File tree

src/components/CoursePalette.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
// Reserved pane for the course palette. A later ticket fills this with course
2+
// tiles (built from the same `courseList`/`courses` data the Explorer uses) that
3+
// students drag onto term slots — course tiles are consumed once placed, blanket
4+
// tiles (electives) are reusable. For now it's a labelled placeholder so the
5+
// two-pane layout contract is locked and the palette ticket only fills the body.
6+
//
7+
// Hidden below md to keep narrow screens to the grid alone.
8+
export default function CoursePalette() {
9+
return (
10+
<aside className="hidden w-56 shrink-0 flex-col border-r border-gray-200 p-4 md:flex">
11+
<h2 className="text-sm font-semibold text-gray-800">Courses</h2>
12+
<p className="mt-2 text-xs text-gray-400">
13+
Drag-and-drop course palette — coming soon.
14+
</p>
15+
</aside>
16+
);
17+
}

src/components/Slot.tsx

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { useState, type SyntheticEvent } from 'react';
2+
import { courses } from '@/data/loadCourses';
3+
import { usePlannerStore } from '@/store/plannerStore';
4+
import type { PlannerEntry } from '@/types/planner';
5+
6+
interface Props {
7+
termId: string;
8+
index: number;
9+
entry: PlannerEntry | null;
10+
}
11+
12+
function normalize(raw: string): string {
13+
return raw.trim().toUpperCase().replace(/\s+/g, ' ');
14+
}
15+
16+
// One course slot box. For now it's a typeable text field holding a course code;
17+
// a later ticket turns it into a drag-and-drop target. The slot's positional
18+
// identity is (termId, index) — committing writes through the store's setSlot.
19+
export default function Slot({ termId, index, entry }: Props) {
20+
const setSlot = usePlannerStore((s) => s.setSlot);
21+
22+
const filledCode = entry?.kind === 'course' ? entry.code : '';
23+
const [input, setInput] = useState(filledCode);
24+
const [error, setError] = useState<string | null>(null);
25+
26+
// Re-sync the box when the committed code changes underneath us (e.g. a
27+
// template load or future drag-drop replaces the slot). Adjusting state during
28+
// render — React's recommended alternative to a syncing effect.
29+
const [lastFilled, setLastFilled] = useState(filledCode);
30+
if (filledCode !== lastFilled) {
31+
setLastFilled(filledCode);
32+
setInput(filledCode);
33+
setError(null);
34+
}
35+
36+
// elective / choose entries aren't typeable yet (separate ticket). Render them
37+
// read-only so a future template's placeholder can't be silently overwritten.
38+
if (entry !== null && entry.kind !== 'course') {
39+
const label =
40+
entry.kind === 'elective' ? entry.category : entry.description;
41+
return (
42+
<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">
43+
{label}
44+
</div>
45+
);
46+
}
47+
48+
function commit(e: SyntheticEvent) {
49+
e.preventDefault();
50+
const code = normalize(input);
51+
52+
if (code === '') {
53+
if (filledCode !== '') setSlot(termId, index, null);
54+
setError(null);
55+
return;
56+
}
57+
58+
if (!courses.has(code)) {
59+
setError('Unknown course code');
60+
return;
61+
}
62+
63+
setSlot(termId, index, { kind: 'course', code });
64+
setInput(code);
65+
setError(null);
66+
}
67+
68+
return (
69+
<form onSubmit={commit} className="flex h-12 flex-col justify-center">
70+
<input
71+
type="text"
72+
value={input}
73+
onChange={(e) => {
74+
setInput(e.target.value);
75+
setError(null);
76+
}}
77+
onBlur={commit}
78+
placeholder="e.g. COMP 1405"
79+
aria-label={`Course slot ${index + 1}`}
80+
aria-invalid={error !== null}
81+
className={`w-full rounded border px-2 py-1 text-xs ${
82+
error !== null ? 'border-red-500' : 'border-gray-300'
83+
}`}
84+
/>
85+
{error !== null && (
86+
<p className="px-1 text-[10px] text-red-600">{error}</p>
87+
)}
88+
</form>
89+
);
90+
}

src/components/TermCell.tsx

Lines changed: 0 additions & 86 deletions
This file was deleted.

src/components/TermColumn.tsx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import type { Term } from '@/store/plannerStore';
2+
import Slot from './Slot';
3+
4+
interface Props {
5+
term: Term;
6+
}
7+
8+
// One term's column of slot boxes. Renders off `term.slots` (never a global
9+
// count) so a future "add slot" ticket that grows a single term's array works
10+
// without touching this component. A later ticket makes this a DnD drop zone.
11+
export default function TermColumn({ term }: Props) {
12+
return (
13+
<div className="flex flex-col gap-2">
14+
{/*
15+
Key on the slot's stable id, not its index: indices shift when a future
16+
remove-slot / reorder ticket moves slots, and an index key would reattach
17+
a box's local input/error state to the wrong row. setSlot is still
18+
index-addressed — the id is for identity, the index for mutation.
19+
*/}
20+
{term.slots.map((slot, index) => (
21+
<Slot key={slot.id} termId={term.id} index={index} entry={slot.entry} />
22+
))}
23+
</div>
24+
);
25+
}

src/components/TermGrid.tsx

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { seasonLabel, type Term } from '@/store/plannerStore';
2+
import TermColumn from './TermColumn';
3+
4+
interface Props {
5+
terms: Term[];
6+
}
7+
8+
const ORDINALS = ['First', 'Second', 'Third', 'Fourth', 'Fifth', 'Sixth'];
9+
10+
function yearLabel(year: number): string {
11+
const ordinal = ORDINALS[year - 1];
12+
return ordinal ? `${ordinal} Year` : `Year ${year}`;
13+
}
14+
15+
// Groups consecutive terms by year, preserving the earliest-first order. Each
16+
// group drives one year header that spans its terms' columns.
17+
function groupByYear(terms: Term[]): { year: number; terms: Term[] }[] {
18+
const groups: { year: number; terms: Term[] }[] = [];
19+
for (const term of terms) {
20+
const last = groups[groups.length - 1];
21+
if (last && last.year === term.year) last.terms.push(term);
22+
else groups.push({ year: term.year, terms: [term] });
23+
}
24+
return groups;
25+
}
26+
27+
// Term-by-term grid: a year-header band on top, Fall/Winter sub-headers, then a
28+
// column of slot boxes per term. Everything is derived from the `terms` array —
29+
// no hardcoded term/year count. A single CSS grid (one track per term) keeps the
30+
// year spans and the slot rows aligned across columns.
31+
//
32+
// TODO(ticket: planner legend) — a legend for connector-line / category-colour
33+
// meaning belongs above this band once those features land.
34+
export default function TermGrid({ terms }: Props) {
35+
const yearGroups = groupByYear(terms);
36+
37+
return (
38+
<div className="overflow-x-auto">
39+
<div
40+
className="grid min-w-full gap-x-3 gap-y-2"
41+
style={{
42+
gridTemplateColumns: `repeat(${terms.length}, minmax(11rem, 1fr))`,
43+
}}
44+
>
45+
{/* Row 1 — year headers, each spanning its terms' columns. */}
46+
{yearGroups.map((group) => (
47+
<div
48+
key={group.year}
49+
className="rounded bg-gray-100 py-1 text-center text-xs font-semibold tracking-wide text-gray-700 uppercase"
50+
style={{ gridColumn: `span ${group.terms.length}` }}
51+
>
52+
{yearLabel(group.year)}
53+
</div>
54+
))}
55+
56+
{/* Row 2 — Fall/Winter (season) sub-headers, one per term. */}
57+
{terms.map((term) => (
58+
<div
59+
key={term.id}
60+
className="text-center text-xs font-medium text-gray-500"
61+
>
62+
{seasonLabel(term.season)}
63+
</div>
64+
))}
65+
66+
{/* Row 3 — one slot column per term. */}
67+
{terms.map((term) => (
68+
<TermColumn key={term.id} term={term} />
69+
))}
70+
</div>
71+
</div>
72+
);
73+
}

src/pages/Planner.tsx

Lines changed: 22 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,65 +1,34 @@
11
// TODO (volunteer tickets):
2-
// - Drag-and-drop courses between terms
3-
// - Course palette sidebar with draggable course cards
4-
// - Autocomplete on the course input
5-
// - Co-op term special rendering (when COOP courses are added)
6-
// - Configurable program length (not always 8 terms)
7-
// - Polished violation rendering (cards, line numbers, jump-to)
8-
// - Plan export/import as JSON
9-
// - Share plan via URL hash
10-
// - Reset plan button with confirm dialog
11-
// - Render elective placeholder entries (with category label, italic, distinct border color)
12-
// - Render choose-from-set entries (with credit count + description)
13-
// - "Start from template" dropdown at the top of the planner; loads a ProgramTemplate via the store's loadTemplate action
14-
// - Template freshness disclaimer banner shown when a template is loaded (uses validFor and lastReviewed fields)
15-
// - First curated template content: BCS General (separate PR, content not engineering)
16-
// - Subsequent templates: BCS Honours, BCS SE Stream, BCS AI Stream, BMath Data Science, Cybersecurity minor
17-
// - In-planner course detail panel (shared component with Explorer)
18-
// - In-planner prereq highlighting (click a course, highlight its prereqs in earlier terms and unlocks in later terms)
2+
// - Course palette panel + drag-and-drop course tiles onto slots (see CoursePalette)
3+
// - Prereq validation: re-add the violation banner (validatePlan + ViolationList
4+
// still exist; map each term's slots → compact entries, dropping nulls)
5+
// - "Start from template" dropdown (store's loadTemplate) + freshness disclaimer
6+
// - Render elective / choose entry kinds as styled tiles
7+
// - "Add slot" / drag-overflow to grow a term up to MAX_SLOTS_PER_TERM
8+
// - Add / remove terms (summer, year 5+); co-op term rendering
9+
// - Prereq connector lines between slots; in-planner course detail panel
10+
// - Plan export/import as JSON; share via URL hash; reset-plan button
1911

20-
import { useMemo } from 'react';
21-
import { usePlannerStore, termLabel } from '@/store/plannerStore';
22-
import { courses } from '@/data/loadCourses';
23-
import { validatePlan } from '@/lib/validatePlan';
24-
import TermCell from '@/components/TermCell';
25-
import ViolationList from '@/components/ViolationList';
12+
import { usePlannerStore } from '@/store/plannerStore';
13+
import CoursePalette from '@/components/CoursePalette';
14+
import TermGrid from '@/components/TermGrid';
2615

2716
export default function Planner() {
2817
const terms = usePlannerStore((s) => s.terms);
2918

30-
const violations = useMemo(
31-
() =>
32-
validatePlan(
33-
terms.map((t) => ({
34-
termId: t.id,
35-
label: termLabel(t),
36-
entries: t.entries,
37-
})),
38-
courses,
39-
),
40-
[terms],
41-
);
42-
4319
return (
44-
<div className="flex h-full flex-col gap-4 overflow-y-auto p-4">
45-
<ViolationList violations={violations} />
20+
<div className="flex h-full overflow-hidden">
21+
<CoursePalette />
4622

47-
<p className="rounded bg-yellow-100 px-3 py-2 text-sm text-yellow-800">
48-
This tool validates prerequisite ordering only. It does not check
49-
whether courses are offered in specific terms. Verify with the
50-
registrar.
51-
</p>
23+
<main className="flex flex-1 flex-col gap-4 overflow-auto p-4">
24+
<p className="rounded bg-yellow-100 px-3 py-2 text-sm text-yellow-800">
25+
This tool validates prerequisite ordering only. It does not check
26+
whether courses are offered in specific terms. Verify with the
27+
registrar.
28+
</p>
5229

53-
<div className="grid grid-cols-2 gap-3">
54-
{terms.map((term) => (
55-
<TermCell
56-
key={term.id}
57-
termId={term.id}
58-
label={termLabel(term)}
59-
entries={term.entries}
60-
/>
61-
))}
62-
</div>
30+
<TermGrid terms={terms} />
31+
</main>
6332
</div>
6433
);
6534
}

0 commit comments

Comments
 (0)