From 00407e1a15d496db5bdbb89915c7ea10dcadd9e5 Mon Sep 17 00:00:00 2001 From: Kieran Klukas Date: Mon, 19 Oct 2026 18:38:47 -0400 Subject: [PATCH] feat: add add button to draw view --- src/client/app.css | 22 ++++++++++ src/client/views/plan.ts | 78 +++++++++++++++++++++++----------- src/client/views/views.test.ts | 13 ++++++ 3 files changed, 88 insertions(+), 25 deletions(-) diff --git a/src/client/app.css b/src/client/app.css index 2fc3fd6..2ce26bb 100644 --- a/src/client/app.css +++ b/src/client/app.css @@ -980,6 +980,28 @@ details.req > summary { color: var(--lavender); } +.graph-add { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 8px; + font-size: 12px; +} + +.graph-add .add-course, +.graph-add .add-term { + padding: 2px 6px; + font: inherit; + color: var(--text); + background: var(--mantle); + border: 1px solid var(--surface); + border-radius: 3px; +} + +.graph-add .add-course { + width: 26ch; +} + .term h3 .add-course { width: 12ch; padding: 1px 4px; diff --git a/src/client/views/plan.ts b/src/client/views/plan.ts index 4100a0a..f0d101e 100644 --- a/src/client/views/plan.ts +++ b/src/client/views/plan.ts @@ -349,6 +349,43 @@ export function mount(root: HTMLElement, ctx: Ctx) { return catalogue; }; + /** Puts a catalog course in a term, as its next sitting if it is already held. False if unknown. */ + function addCourse(typed: string, term: string): boolean { + const asked = baseCode(typed.trim().toUpperCase()); + if (!graph.courses.has(asked)) return false; + const held = projection.scheduled(store.get().moves); + const taken = (code: string) => held.has(code) || planning.have.has(code); + let nth = 1; + while (taken(sittingCode(asked, nth))) nth++; + place(planning.sitting(asked, nth), term); + return true; + } + + /** The graph has no term boxes to hang a + on, so it gets one bar above it. */ + function graphAdder(terms: string[]) { + const bar = el("div", "graph-add"); + const input = el("input", "add-course"); + input.type = "text"; + input.placeholder = "add a course, e.g. HON-3020"; + input.setAttribute("list", options().id); + const into = el("select", "add-term"); + for (const term of terms) + into.append(Object.assign(el("option"), { value: term, textContent: term })); + const go = el("button", "add"); + go.type = "button"; + go.textContent = "+"; + go.title = "Add this course to the chosen term."; + const take = () => { + if (!addCourse(input.value, into.value)) input.select(); + }; + go.addEventListener("click", take); + input.addEventListener("keydown", (event) => { + if ((event as KeyboardEvent).key === "Enter") take(); + }); + bar.append(input, el("span", "muted", "in"), into, go); + return bar; + } + /** The one control that adds work rather than rearranging it. */ function adder(name: string) { const add = el("button", "add"); @@ -363,19 +400,7 @@ export function mount(root: HTMLElement, ctx: Ctx) { input.setAttribute("list", options().id); const take = () => { - const asked = baseCode(input.value.trim().toUpperCase()); - // Adding a course nothing in the catalog lists would plan a ghost. - if (!graph.courses.has(asked)) { - input.hidden = true; - return; - } - // Asking again for a course planned or already taken means its next sitting. - const { moves } = store.get(); - const held = projection.scheduled(moves); - const taken = (code: string) => held.has(code) || planning.have.has(code); - let nth = 1; - while (taken(sittingCode(asked, nth))) nth++; - place(planning.sitting(asked, nth), name); + if (!addCourse(input.value, name)) input.hidden = true; }; add.addEventListener("click", () => { @@ -506,11 +531,24 @@ export function mount(root: HTMLElement, ctx: Ctx) { dragging = null; body.replaceChildren(); + // Every slot up to one past the last with work in it, so there is + // always a term ahead to drag something into — and so an empty term + // in the middle stays visible rather than closing the gap silently. + const placed = new Map(plan.terms.map((t) => [t.slot.name, t])); + const slots = planning.slots(load); + let last = -1; + slots.forEach((s, at) => { + if (placed.has(s.name)) last = at; + }); + const open = slots.slice(0, last + 2); + if (drawn === "graph") { // The picture is the tab's own default, so the moves have to work // there too: a feature that only exists after you find a button is // a feature most people never find. - picture = mountGraph(body, plan, planning, { onMove: place, onRelease: release }); + const drawing = el("div"); + body.append(graphAdder(open.map((s) => s.name)), drawing); + picture = mountGraph(drawing, plan, planning, { onMove: place, onRelease: release }); return; } @@ -525,17 +563,7 @@ export function mount(root: HTMLElement, ctx: Ctx) { ); } - // Every slot up to one past the last with work in it, so there is - // always a term ahead to drag something into — and so an empty term - // in the middle stays visible rather than closing the gap silently. - const placed = new Map(plan.terms.map((t) => [t.slot.name, t])); - const slots = planning.slots(load); - let last = -1; - slots.forEach((s, at) => { - if (placed.has(s.name)) last = at; - }); - - for (const slot of slots.slice(0, last + 2)) { + for (const slot of open) { const term = placed.get(slot.name); const credits = term?.credits ?? 0; const box = el("div", `term ${slot.season}${term ? "" : " empty"}`); diff --git a/src/client/views/views.test.ts b/src/client/views/views.test.ts index ea2f920..dbd7efe 100644 --- a/src/client/views/views.test.ts +++ b/src/client/views/views.test.ts @@ -1258,6 +1258,19 @@ describe("plan view — arguing with the plan", () => { expect(terms().flatMap(codesIn)).toContain("CS-3310"); }); + test("the drawn plan can add a course too", () => { + localStorage.setItem("cedarville:plan-shape", JSON.stringify("graph")); + plan.mount(root, ctx()); + const bar = root.querySelector(".graph-add") as HTMLElement; + const input = bar.querySelector("input.add-course") as HTMLInputElement; + const into = bar.querySelector("select.add-term") as HTMLSelectElement; + into.value = into.options[1]!.value; + input.value = "CS-1210"; + (bar.querySelector("button.add") as HTMLElement).click(); + expect(JSON.parse(localStorage.getItem("cedarville:moves")!)["CS-1210#2"]).toBe(into.value); + localStorage.removeItem("cedarville:plan-shape"); + }); + test("a second sitting waits on the same prerequisites the first did", () => { // CS-2210 sits behind CS-1210, so a second CS-2210 cannot land in the // opening term either. -- knotmirror 0.0.1