diff --git a/README.md b/README.md index 5805b50..dad01a1 100644 --- a/README.md +++ b/README.md @@ -220,10 +220,10 @@ on Ohio State alone. Only the newest search is allowed to draw, so a slow first search can never paint over a fast second one. -That diagram is only the part a search waits on. Opening the page is 30 requests +That diagram is only the part a search waits on. Opening the page is 32 requests across two hosts, counted in Chrome against a local copy with the cache -cleared: 29 files from this repo and the term list from `content.osu.edu`. The -29 are the HTML, two stylesheets, three font files, nineteen modules, the +cleared: 31 files from this repo and the term list from `content.osu.edu`. The +31 are the HTML, two stylesheets, three font files, 21 modules, the ratings snapshot, two seat files and the favicon. The live page adds one more, the page-view ping to the analytics worker, which local runs skip. @@ -283,6 +283,8 @@ js/ render.js list view, grouped by instructor calendar.js week grid schedule.js saved sections, sharing and conflict detection + generator.js linked packages, conflict search and schedule scoring + generator-render.js automatic builder controls and ranked choices detail.js right pane filters.js client-side filtering sort.js ordering by rating, difficulty, seats or start time diff --git a/css/finder.css b/css/finder.css index adbd6c9..5a84bbd 100644 --- a/css/finder.css +++ b/css/finder.css @@ -1037,6 +1037,175 @@ body { .plan-open span:nth-child(n + 3) { grid-column: 1 / -1; } } +/* Automatic schedule generator ------------------------------------------ */ + +.generator { max-width: 58rem; } + +.gen-head, +.gen-result-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; +} + +.gen-title, +.gen-result-title, +.gen-subtitle { + margin: 0; + font-family: var(--display); + letter-spacing: -0.02em; +} + +.gen-title { font-size: 1.45rem; } +.gen-result-title { font-size: 1.15rem; } +.gen-subtitle { font-size: 1rem; margin-bottom: 0.7rem; } +.gen-intro { max-width: 46rem; margin: 0.6rem 0 1.25rem; color: var(--mute); line-height: 1.5; } + +.gen-add { + display: grid; + grid-template-columns: minmax(10rem, 20rem) auto; + align-items: end; + gap: 0.45rem; + margin-bottom: 0.65rem; +} + +.gen-add .f-label { grid-column: 1 / -1; margin: 0; } + +.gen-add-button, +.gen-generate, +.gen-use { + min-height: 2.35rem; + padding: 0.45rem 0.8rem; + border: 1px solid var(--scarlet); + border-radius: var(--radius); + background: var(--scarlet); + color: var(--paper); + font: inherit; + font-size: 0.82rem; + font-weight: 600; + cursor: pointer; +} + +.gen-generate:disabled { opacity: 0.5; cursor: default; } + +.gen-courses { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + min-height: 2rem; + margin: 0 0 1.2rem; + padding: 0; + list-style: none; +} + +.gen-course-chip { + padding: 0.35rem 0.55rem; + border: 1px solid var(--field); + border-radius: 999px; + background: var(--paper); + color: var(--ink); + font-family: var(--data); + font-size: 0.75rem; + cursor: pointer; +} + +.gen-course-chip:hover { border-color: var(--scarlet); color: var(--scarlet); } + +.gen-preferences { + margin-bottom: 1.2rem; + padding: 1rem; + border: 1px solid var(--rule); + border-radius: var(--radius); + background: var(--paper); +} + +.gen-bounds { + display: grid; + grid-template-columns: minmax(9rem, 12rem) minmax(9rem, 12rem) minmax(14rem, 1fr); + align-items: end; + gap: 0.7rem; +} + +.gen-bounds .f-label { margin: 0; } +.gen-bounds .f-ctl { display: block; margin-top: 0.3rem; } + +.gen-check { + display: flex; + align-items: center; + gap: 0.45rem; + min-height: 2.35rem; + font-size: 0.8rem; +} + +.gen-weights { + display: grid; + grid-template-columns: repeat(2, minmax(12rem, 1fr)); + gap: 0.5rem 1rem; + margin: 1rem 0; +} + +.gen-weight { + display: grid; + grid-template-columns: minmax(8rem, 1fr) minmax(6rem, 1fr); + align-items: center; + gap: 0.6rem; + color: var(--mute); + font-size: 0.78rem; +} + +.gen-weight input { width: 100%; accent-color: var(--scarlet); } +.gen-note { margin: 0 0 1rem; color: var(--mute); font-size: 0.85rem; } +.gen-note.is-error { color: var(--scarlet); } +.gen-results { margin-top: 1.4rem; } + +.gen-result { + margin-bottom: 1rem; + padding: 1rem; + border: 1px solid var(--rule); + border-radius: var(--radius); + background: var(--paper); +} + +.gen-result:first-of-type { border-color: var(--open); } +.gen-use { background: transparent; color: var(--scarlet); } +.gen-use:hover { background: var(--scarlet); color: var(--paper); } + +.gen-metrics { + display: grid; + grid-template-columns: repeat(6, minmax(5.5rem, 1fr)); + gap: 0.6rem; + margin: 0.9rem 0; +} + +.gen-metric { min-width: 0; } +.gen-metric strong { display: block; font-family: var(--data); font-size: 0.9rem; overflow-wrap: anywhere; } +.gen-metric span { display: block; color: var(--mute); font-size: 0.68rem; } + +.gen-sections { margin: 0; padding: 0; border-top: 1px solid var(--rule); list-style: none; } +.gen-sections li { display: grid; grid-template-columns: 8rem 1fr 1fr; gap: 0.7rem; padding: 0.55rem 0; border-bottom: 1px solid var(--rule); } +.gen-sections span { color: var(--mute); font-size: 0.78rem; } + +@media (max-width: 46rem) { + .gen-head { display: block; } + .gen-head .plan-action { margin-top: 0.7rem; } + .gen-bounds { grid-template-columns: 1fr 1fr; } + .gen-check { grid-column: 1 / -1; } + .gen-weights { grid-template-columns: 1fr; } + .gen-metrics { grid-template-columns: repeat(3, 1fr); } + .gen-sections li { grid-template-columns: 1fr; gap: 0.15rem; } +} + +@media (max-width: 28rem) { + .gen-add { grid-template-columns: 1fr; } + .gen-add-button { grid-column: 1; } + .gen-bounds { grid-template-columns: 1fr; } + .gen-check { grid-column: 1; } + .gen-result-head { display: block; } + .gen-use { margin-top: 0.7rem; } + .gen-metrics { grid-template-columns: repeat(2, 1fr); } +} + .cal-scroll { overflow-x: auto; } .cal-scroll:focus-visible { outline: 2px solid var(--scarlet); outline-offset: 2px; } diff --git a/js/app.js b/js/app.js index b345dd7..febbefd 100644 --- a/js/app.js +++ b/js/app.js @@ -1,9 +1,9 @@ import { fetchTerms, defaultTerm, searchAllPages, GEN_CATEGORIES, ApiError } from "./api.js"; import { filterCourses, parseQuery } from "./rank.js"; import { renderResults } from "./render.js"; -import { loadRatings, loadRatingCourses, topRated, ratedCount, profileUrl, ratingsFailed } from "./ratings.js"; -import { loadGrades } from "./grades.js"; -import { linkedTo, loadSeats, seatsTerm, seatsUpdated, seatsSectionCount, seatsFailed, withSeatInstructors } from "./seats.js"; +import { loadRatings, loadRatingCourses, topRated, ratedCount, profileUrl, ratingFor, ratingsFailed } from "./ratings.js"; +import { gradesFor, loadGrades } from "./grades.js"; +import { linkedTo, loadSeats, seatsFor, seatsTerm, seatsUpdated, seatsSectionCount, seatsFailed, withSeatInstructors } from "./seats.js"; import { loadTrend } from "./trend.js"; import { renderDetail } from "./detail.js"; import { loadHeadshots } from "./headshots.js"; @@ -18,6 +18,8 @@ import { addScheduleItem, formatPlan, isScheduled, loadSchedule, parsePlan, removeScheduleItem, renderSchedule, saveSchedule, scheduleEntries, scheduleIds, scheduleKey, } from "./schedule.js"; +import { DEFAULT_GENERATOR_WEIGHTS, describePackage, expandCoursePackages, generateSchedules } from "./generator.js"; +import { renderGenerator } from "./generator-render.js"; const els = { app: document.querySelector(".app"), @@ -88,6 +90,14 @@ let pickedSearch = null; let view = "list"; let schedule = loadSchedule(); let scheduleNote = ""; +let generator = { + desired: [], + preferences: { earliest: "", latest: "", excludeFull: true, weights: { ...DEFAULT_GENERATOR_WEIGHTS } }, + results: [], + phase: "idle", + note: "", +}; +let generatorRequest = 0; // A section named by the URL. Applied on the next paint and then forgotten, so // changing a filter later does not drag the pane back to it. let pendingClass = ""; @@ -380,9 +390,10 @@ function searchFromPickers() { function setView(next) { view = next; + const selected = next === "generator" ? "schedule" : next; for (const [button, name] of [[els.viewList, "list"], [els.viewCal, "calendar"], [els.viewSchedule, "schedule"]]) { - button.classList.toggle("is-on", name === next); - button.setAttribute("aria-pressed", String(name === next)); + button.classList.toggle("is-on", name === selected); + button.setAttribute("aria-pressed", String(name === selected)); } if (next !== "schedule" && !lastResult) { els.results.replaceChildren(); @@ -555,6 +566,10 @@ function paintSchedule(term = els.term.value) { els.results.focus(); }, onShare: shareSchedule, + onGenerate: () => { + setView("generator"); + els.results.focus(); + }, })); resetDetail(); els.app.dataset.view = "results"; @@ -562,6 +577,163 @@ function paintSchedule(term = els.term.value) { scheduleNote = ""; } +const COURSE_CODE = /^([A-Za-z]{2,8})\s+(\d{3,4}(?:\.\d+)?[A-Za-z]*)$/; + +function normalizeCourseCode(raw) { + const match = COURSE_CODE.exec(String(raw ?? "").trim()); + return match ? `${match[1].toUpperCase()} ${match[2].toUpperCase()}` : ""; +} + +function addDesiredCourse(raw) { + const code = normalizeCourseCode(raw); + if (!code) { + generator.note = "Enter a subject and course number, like CSE 2221."; + generator.phase = "error"; + paintGenerator(); + return false; + } + if (!generator.desired.includes(code)) generator.desired.push(code); + generator.results = []; + generator.phase = "idle"; + generator.note = ""; + paintGenerator(); + return true; +} + +function removeDesiredCourse(code) { + generator.desired = generator.desired.filter((candidate) => candidate !== code); + generator.results = []; + generator.phase = "idle"; + generator.note = ""; + paintGenerator(); +} + +function exactCourse(courses, code) { + const [subject, catalogNumber] = code.split(" "); + return (courses ?? []).find((entry) => + String(entry.course?.subject).toUpperCase() === subject + && String(entry.course?.catalogNumber).toUpperCase() === catalogNumber + ) ?? null; +} + +async function coursePackages(code, term) { + const result = await searchAllPages({ q: code, term }); + let entry = exactCourse(result.courses, code); + if (!entry) return { code, packages: [], missingCourse: true, missingLinked: [] }; + entry = withSeatInstructors([entry], term)[0]; + + // An exact course search normally carries every sibling section. If the API + // omits a linked partner, resolve that class number exactly before refusing + // to generate incomplete registration packages. + let expanded = expandCoursePackages(entry, term, linkedTo); + if (expanded.missing.length) { + const pulled = await Promise.allSettled(expanded.missing.map((number) => searchAllPages({ q: number, term }))); + const additions = []; + pulled.forEach((answer, index) => { + if (answer.status !== "fulfilled") return; + const wanted = expanded.missing[index]; + for (const found of answer.value.courses ?? []) { + const section = (found.sections ?? []).find((candidate) => String(candidate.classNumber) === String(wanted)); + if (section) additions.push(section); + } + }); + entry = { ...entry, sections: [...entry.sections, ...additions] }; + expanded = expandCoursePackages(entry, term, linkedTo); + } + + return { + code, + packages: expanded.packages.map((pkg) => describePackage(pkg, term, { seatsFor, ratingFor, gradesFor })), + missingCourse: false, + missingLinked: expanded.missing, + }; +} + +async function runGenerator(preferences) { + generator.preferences = preferences; + generator.phase = "loading"; + generator.note = `Reading sections for ${generator.desired.length} courses…`; + generator.results = []; + paintGenerator(); + + const request = ++generatorRequest; + const term = els.term.value; + try { + await Promise.all([ + loadSeats(term), + loadRatings().catch(() => null), + loadGrades().catch(() => null), + ]); + const groups = await Promise.all(generator.desired.map((code) => coursePackages(code, term))); + if (request !== generatorRequest) return; + + const missing = groups.filter((group) => group.missingCourse).map((group) => group.code); + const incomplete = groups.filter((group) => group.missingLinked.length); + if (missing.length || incomplete.length) { + const notes = []; + if (missing.length) notes.push(`Not offered: ${missing.join(", ")}.`); + if (incomplete.length) notes.push(`Could not resolve every linked section for ${incomplete.map((group) => group.code).join(", ")}.`); + generator.phase = "error"; + generator.note = notes.join(" "); + paintGenerator(); + return; + } + + const generated = generateSchedules(groups, { + busy: busyBlocks(), + weights: preferences.weights, + earliest: fieldMinutes(preferences.earliest), + latest: fieldMinutes(preferences.latest), + excludeFull: preferences.excludeFull, + }); + generator.results = generated.results; + generator.phase = "ready"; + const suffix = generated.truncated ? " Finder stopped after checking 100,000 choices, so this is the best set checked rather than every possible schedule." : ""; + generator.note = generated.results.length + ? `${generated.results.length} best conflict-free schedules from ${generated.visited.toLocaleString()} compatible choices.${suffix}` + : "No conflict-free schedule fits these courses and constraints. Try widening the time window, allowing full sections, or changing a busy period."; + paintGenerator(); + } catch (error) { + if (request !== generatorRequest) return; + generator.phase = "error"; + generator.note = error instanceof ApiError ? error.message : "Could not generate schedules. Try again."; + paintGenerator(); + } +} + +function useGeneratedSchedule(result, term = els.term.value) { + schedule = schedule.filter((item) => String(item.term) !== String(term)); + for (const pkg of result.packages) { + const base = [...pkg.sections].sort((a, b) => + (linkedTo(b.classNumber, term)?.enrolls.length ?? 0) - (linkedTo(a.classNumber, term)?.enrolls.length ?? 0) + )[0]; + const included = pkg.sections + .filter((section) => String(section.classNumber) !== String(base.classNumber)) + .map((section) => ({ course: pkg.course, section })); + schedule = addScheduleItem(schedule, { term, course: pkg.course, section: base, included }); + } + scheduleNote = `Using generated schedule with ${result.packages.length} courses.`; + storeSchedule(); + setView("schedule"); + els.results.focus(); +} + +function paintGenerator(term = els.term.value) { + view = "generator"; + els.welcome.hidden = true; + els.results.replaceChildren(renderGenerator({ + ...generator, + onBack: () => setView("schedule"), + onAdd: addDesiredCourse, + onRemove: removeDesiredCourse, + onGenerate: runGenerator, + onUse: (result) => useGeneratedSchedule(result, term), + })); + resetDetail(); + els.app.dataset.view = "results"; + setStatus(generator.note); +} + function sectionLink(classNumber) { return setClassParam(new URL(location.href), classNumber).href; } @@ -946,6 +1118,7 @@ function paint(term = els.term.value) { markSources(term); els.scheduleCount.textContent = String(scheduleIds(schedule, term).length); if (view === "schedule") { paintSchedule(term); return; } + if (view === "generator") { paintGenerator(term); return; } const filters = readFilters(); // The clear button tracks the filters, not the result, so it is set before // the bail below. diff --git a/js/generator-render.js b/js/generator-render.js new file mode 100644 index 0000000..96cf548 --- /dev/null +++ b/js/generator-render.js @@ -0,0 +1,188 @@ +import { formatWhen, instructorsOf } from "./format.js"; + +const WEIGHTS = [ + ["seats", "Open seats"], + ["rating", "Instructor ratings"], + ["grades", "Grade history"], + ["lateStart", "Later starts"], + ["earlyEnd", "Earlier finishes"], + ["compactDays", "Fewer campus days"], + ["compactGaps", "Shorter gaps"], +]; + +function el(tag, className, text) { + const node = document.createElement(tag); + if (className) node.className = className; + if (text != null) node.textContent = text; + return node; +} + +function clock(minutes) { + if (!Number.isFinite(minutes)) return "—"; + const hour = Math.floor(minutes / 60); + const shown = hour % 12 || 12; + return `${shown}:${String(Math.round(minutes) % 60).padStart(2, "0")}${hour < 12 ? "a" : "p"}`; +} + +function metric(value, label) { + const item = el("div", "gen-metric"); + item.append(el("strong", null, value), el("span", null, label)); + return item; +} + +function resultCard(result, index, onUse) { + const card = el("article", "gen-result"); + const head = el("header", "gen-result-head"); + const title = el("div"); + title.append(el("p", "eyebrow", `Option ${index + 1}`)); + title.append(el("h3", "gen-result-title", `${Math.round(result.score)} match`)); + const use = el("button", "gen-use", "Use this schedule"); + use.type = "button"; + use.addEventListener("click", () => onUse(result)); + head.append(title, use); + card.append(head); + + const metrics = el("div", "gen-metrics"); + const m = result.metrics; + metrics.append( + metric(m.minSeats == null ? "—" : String(m.minSeats), "minimum seats"), + metric(m.rating == null ? "—" : m.rating.toFixed(1), "average rating"), + metric(m.gpa == null ? "—" : m.gpa.toFixed(2), "historical GPA"), + metric(String(m.days), "campus days"), + metric(`${m.gaps} min`, "weekly gaps"), + metric(`${clock(m.averageStart)}–${clock(m.averageEnd)}`, "average day"), + ); + card.append(metrics); + + const list = el("ul", "gen-sections"); + for (const pkg of result.packages) { + const item = el("li"); + const code = `${pkg.course.subject} ${pkg.course.catalogNumber}`; + const numbers = pkg.sections.map((section) => section.classNumber).join(", "); + const people = [...new Set(pkg.sections.flatMap((section) => instructorsOf(section).map((person) => person.name)))]; + const first = pkg.sections.flatMap((section) => section.meetings ?? [])[0]; + item.append(el("strong", null, code)); + item.append(el("span", null, `Sections ${numbers}${first ? ` · ${formatWhen(first)}` : ""}`)); + if (people.length) item.append(el("span", null, people.join(" & "))); + list.append(item); + } + card.append(list); + return card; +} + +export function renderGenerator({ + desired, preferences, results = [], phase = "idle", note = "", + onBack, onAdd, onRemove, onGenerate, onUse, +}) { + const wrap = el("section", "generator"); + const head = el("header", "gen-head"); + const heading = el("div"); + heading.append(el("p", "eyebrow", "Automatic schedule generator")); + heading.append(el("h2", "gen-title", "Build a conflict-free schedule")); + const back = el("button", "plan-action", "Back to my schedule"); + back.type = "button"; + back.addEventListener("click", onBack); + head.append(heading, back); + wrap.append(head); + wrap.append(el("p", "gen-intro", "Add each course you want. Finder will keep linked lectures, labs and recitations together, apply the busy times in Filters, then rank complete schedules using the priorities below.")); + + const addForm = el("form", "gen-add"); + const label = el("label", "f-label", "Desired course"); + label.setAttribute("for", "gen-course"); + const input = el("input", "f-ctl"); + input.setAttribute("id", "gen-course"); + input.type = "text"; + input.placeholder = "CSE 2221"; + input.autocomplete = "off"; + const add = el("button", "gen-add-button", "Add course"); + add.type = "submit"; + addForm.append(label, input, add); + addForm.addEventListener("submit", (event) => { + event.preventDefault(); + if (onAdd(input.value)) input.value = ""; + }); + wrap.append(addForm); + + const chips = el("ul", "gen-courses"); + for (const code of desired) { + const item = el("li"); + const remove = el("button", "gen-course-chip", `${code} ×`); + remove.type = "button"; + remove.setAttribute("aria-label", `Remove ${code}`); + remove.addEventListener("click", () => onRemove(code)); + item.append(remove); + chips.append(item); + } + wrap.append(chips); + + const form = el("form", "gen-preferences"); + const legend = el("h3", "gen-subtitle", "Constraints and priorities"); + form.append(legend); + + const bounds = el("div", "gen-bounds"); + const earliestLabel = el("label", "f-label", "No class before"); + const earliest = el("input", "f-ctl"); + earliest.type = "time"; + earliest.value = preferences.earliest ?? ""; + earliestLabel.append(earliest); + const latestLabel = el("label", "f-label", "No class after"); + const latest = el("input", "f-ctl"); + latest.type = "time"; + latest.value = preferences.latest ?? ""; + latestLabel.append(latest); + const fullLabel = el("label", "gen-check"); + const excludeFull = el("input"); + excludeFull.type = "checkbox"; + excludeFull.checked = preferences.excludeFull !== false; + fullLabel.append(excludeFull, document.createTextNode("Exclude full registration packages")); + bounds.append(earliestLabel, latestLabel, fullLabel); + form.append(bounds); + + const weights = el("div", "gen-weights"); + const controls = new Map(); + for (const [key, text] of WEIGHTS) { + const control = el("label", "gen-weight"); + control.append(el("span", null, text)); + const range = el("input"); + range.type = "range"; + range.min = "0"; + range.max = "3"; + range.step = "1"; + range.value = String(preferences.weights?.[key] ?? 1); + range.setAttribute("aria-label", `${text} priority, zero to three`); + controls.set(key, range); + control.append(range); + weights.append(control); + } + form.append(weights); + + const generate = el("button", "gen-generate", phase === "loading" ? "Generating…" : "Generate schedules"); + generate.type = "submit"; + generate.disabled = phase === "loading" || desired.length < 2; + form.append(generate); + form.addEventListener("submit", (event) => { + event.preventDefault(); + onGenerate({ + earliest: earliest.value, + latest: latest.value, + excludeFull: excludeFull.checked, + weights: Object.fromEntries([...controls].map(([key, input]) => [key, Number(input.value)])), + }); + }); + wrap.append(form); + + if (note) { + const status = el("p", phase === "error" ? "gen-note is-error" : "gen-note", note); + status.setAttribute("role", phase === "error" ? "alert" : "status"); + wrap.append(status); + } + + if (results.length) { + const list = el("div", "gen-results"); + list.append(el("h3", "gen-subtitle", `${results.length} best schedule${results.length === 1 ? "" : "s"}`)); + results.forEach((result, index) => list.append(resultCard(result, index, onUse))); + wrap.append(list); + } + + return wrap; +} diff --git a/js/generator.js b/js/generator.js new file mode 100644 index 0000000..171392b --- /dev/null +++ b/js/generator.js @@ -0,0 +1,281 @@ +// Automatic schedule generation. This module owns no DOM and performs no +// fetches: app.js supplies complete course entries and the snapshots already +// loaded for the selected term. + +import { instructorsOf } from "./format.js"; +import { toMinutes } from "./filters.js"; + +const DAY_KEYS = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"]; + +export const DEFAULT_GENERATOR_WEIGHTS = { + seats: 1, + rating: 1, + grades: 1, + lateStart: 1, + earlyEnd: 1, + compactDays: 1, + compactGaps: 1, +}; + +function clamp(value, min = 0, max = 100) { + return Math.min(max, Math.max(min, value)); +} + +function sectionRanges(section) { + const found = []; + for (const meeting of section?.meetings ?? []) { + const start = toMinutes(meeting.startTime); + if (start == null) continue; + const end = toMinutes(meeting.endTime) ?? start + 55; + for (const day of DAY_KEYS) { + if (meeting[day]) found.push({ day, start, end, classNumber: String(section.classNumber) }); + } + } + return found; +} + +function overlaps(a, b) { + return a.day === b.day && a.start < b.end && b.start < a.end; +} + +function hasConflict(left, right) { + return left.some((a) => right.some((b) => overlaps(a, b))); +} + +function hasInternalConflict(ranges) { + for (let i = 0; i < ranges.length; i++) { + for (let j = i + 1; j < ranges.length; j++) { + if (ranges[i].classNumber !== ranges[j].classNumber && overlaps(ranges[i], ranges[j])) return true; + } + } + return false; +} + +function uniqueSections(sections) { + return [...new Map(sections.map((section) => [String(section.classNumber), section])).values()]; +} + +function packageKey(sections) { + return sections.map((section) => String(section.classNumber)).sort().join(","); +} + +/** + * Expand one course into complete registration packages. + * + * Barrett's relationship is directional: a child enrolls in every parent in + * `enrolls`, while each parent requires one child from `enrolledBy`. Starting + * from every section and repeatedly satisfying both rules handles a child that + * belongs to two parents without guessing from component labels or times. + */ +export function expandCoursePackages(entry, term, linkedFor) { + const sections = entry?.sections ?? []; + const byNumber = new Map(sections.map((section) => [String(section.classNumber), section])); + const links = new Map(); + const missing = new Set(); + + for (const number of byNumber.keys()) { + const linked = linkedFor(number, term); + const row = { + enrolls: (linked?.enrolls ?? []).map(String), + enrolledBy: (linked?.enrolledBy ?? []).map(String), + }; + links.set(number, row); + for (const partner of [...row.enrolls, ...row.enrolledBy]) if (!byNumber.has(partner)) missing.add(partner); + } + + const complete = new Map(); + const seenStates = new Set(); + + function walk(selected) { + const key = [...selected].sort().join(","); + if (seenStates.has(key)) return; + seenStates.add(key); + + // Selecting a child may require one or several parent sections. + for (const number of [...selected]) { + for (const parent of links.get(number)?.enrolls ?? []) { + if (!byNumber.has(parent)) return; + if (!selected.has(parent)) return walk(new Set([...selected, parent])); + } + } + + // Every selected parent needs one of its listed children. Branch rather + // than choosing the first, since those alternatives are the schedules. + for (const number of [...selected]) { + const choices = links.get(number)?.enrolledBy ?? []; + if (!choices.length || choices.some((child) => selected.has(child))) continue; + for (const child of choices) if (byNumber.has(child)) walk(new Set([...selected, child])); + return; + } + + const members = uniqueSections([...selected].map((number) => byNumber.get(number)).filter(Boolean)); + complete.set(packageKey(members), { course: entry.course, sections: members }); + } + + for (const number of byNumber.keys()) walk(new Set([number])); + return { packages: [...complete.values()], missing: [...missing].sort() }; +} + +function knownAverage(values) { + const known = values.filter((value) => Number.isFinite(value)); + return known.length ? known.reduce((sum, value) => sum + value, 0) / known.length : null; +} + +/** Add ranges and ranking evidence to a registration package. */ +export function describePackage(pkg, term, { seatsFor, ratingFor, gradesFor }) { + const sections = uniqueSections(pkg.sections ?? []); + const ranges = sections.flatMap(sectionRanges); + const seatCounts = sections.map((section) => { + const seats = seatsFor(section.classNumber, term); + return seats ? Math.max(0, seats.limit - seats.enrolled) : null; + }).filter(Number.isFinite); + const hasFull = sections.some((section) => seatsFor(section.classNumber, term)?.full === true); + + const people = new Map(); + for (const section of sections) { + for (const person of instructorsOf(section)) { + const key = person.email || person.name; + if (key && !people.has(key)) people.set(key, person); + } + } + + const ratings = []; + const grades = []; + for (const person of people.values()) { + const rating = ratingFor(person.name); + if (rating && rating.numRatings >= 5 && Number.isFinite(Number(rating.avgRating))) { + ratings.push(Number(rating.avgRating)); + } + const curve = gradesFor(person, pkg.course); + if (curve && Number.isFinite(Number(curve.gpa)) && curve.n >= 10) grades.push(Number(curve.gpa)); + } + + return { + ...pkg, + sections, + ranges, + minSeats: seatCounts.length === sections.length ? Math.min(...seatCounts) : null, + hasFull, + rating: knownAverage(ratings), + gpa: knownAverage(grades), + }; +} + +function scheduleShape(packages) { + const ranges = packages.flatMap((pkg) => pkg.ranges); + const byDay = new Map(); + for (const range of ranges) { + if (!byDay.has(range.day)) byDay.set(range.day, []); + byDay.get(range.day).push(range); + } + + let gaps = 0; + let starts = 0; + let ends = 0; + for (const day of byDay.values()) { + day.sort((a, b) => a.start - b.start || a.end - b.end); + starts += day[0].start; + ends += Math.max(...day.map((range) => range.end)); + let until = day[0].end; + for (const range of day.slice(1)) { + if (range.start > until) gaps += range.start - until; + until = Math.max(until, range.end); + } + } + + const days = byDay.size; + return { + ranges, + days, + gaps, + averageStart: days ? starts / days : null, + averageEnd: days ? ends / days : null, + }; +} + +export function scoreCombination(packages, weights = DEFAULT_GENERATOR_WEIGHTS) { + const shape = scheduleShape(packages); + const minSeats = packages.every((pkg) => Number.isFinite(pkg.minSeats)) + ? Math.min(...packages.map((pkg) => pkg.minSeats)) : null; + const rating = knownAverage(packages.map((pkg) => pkg.rating)); + const gpa = knownAverage(packages.map((pkg) => pkg.gpa)); + + const factors = { + seats: minSeats == null ? null : clamp(minSeats * 10), + rating: rating == null ? null : clamp((rating - 1) * 25), + grades: gpa == null ? null : clamp(gpa * 25), + lateStart: shape.averageStart == null ? null : clamp((shape.averageStart - 420) / 6.6), + earlyEnd: shape.averageEnd == null ? null : clamp((1320 - shape.averageEnd) / 7.2), + compactDays: shape.days ? clamp((6 - shape.days) * 20) : null, + compactGaps: clamp(100 - shape.gaps / 4.8), + }; + + let points = 0; + let totalWeight = 0; + for (const [key, value] of Object.entries(factors)) { + const weight = Math.max(0, Number(weights[key]) || 0); + if (value == null || weight === 0) continue; + points += value * weight; + totalWeight += weight; + } + + return { + score: totalWeight ? points / totalWeight : 0, + metrics: { minSeats, rating, gpa, ...shape }, + factors, + }; +} + +/** + * Pick one package per course, rejecting time conflicts and busy periods. + * The limit is a safety valve for pathological catalogues and is reported to + * the caller rather than silently presenting a partial search as exhaustive. + */ +export function generateSchedules(coursePackages, { + busy = [], weights = DEFAULT_GENERATOR_WEIGHTS, maxResults = 25, maxVisited = 100000, + excludeFull = true, earliest = null, latest = null, +} = {}) { + const busyRanges = busy.flatMap((block) => (block.days ?? []).map((day) => ({ day, start: block.start, end: block.end }))); + const prepared = coursePackages.map((group) => ({ + ...group, + packages: (group.packages ?? []).filter((pkg) => { + if (excludeFull && (pkg.hasFull || pkg.minSeats === 0)) return false; + if (hasInternalConflict(pkg.ranges)) return false; + if (Number.isFinite(earliest) && pkg.ranges.some((range) => range.start < earliest)) return false; + if (Number.isFinite(latest) && pkg.ranges.some((range) => range.end > latest)) return false; + return !hasConflict(pkg.ranges, busyRanges); + }), + })).sort((a, b) => a.packages.length - b.packages.length); + + if (prepared.some((group) => !group.packages.length)) { + return { results: [], visited: 0, truncated: false }; + } + + const results = []; + let visited = 0; + let truncated = false; + + function keep(packages) { + const scored = scoreCombination(packages, weights); + const key = packages.map((pkg) => packageKey(pkg.sections)).sort().join("|"); + results.push({ packages: [...packages], ...scored, key }); + results.sort((a, b) => b.score - a.score || a.key.localeCompare(b.key)); + if (results.length > maxResults) results.length = maxResults; + } + + function walk(index, chosen, occupied) { + if (visited >= maxVisited) { truncated = true; return; } + if (index === prepared.length) { keep(chosen); return; } + + for (const pkg of prepared[index].packages) { + visited += 1; + if (visited > maxVisited) { truncated = true; return; } + if (hasConflict(pkg.ranges, occupied)) continue; + walk(index + 1, [...chosen, pkg], [...occupied, ...pkg.ranges]); + if (truncated) return; + } + } + + walk(0, [], []); + return { results, visited, truncated }; +} diff --git a/js/schedule.js b/js/schedule.js index 349b8fd..ccc5c41 100644 --- a/js/schedule.js +++ b/js/schedule.js @@ -141,7 +141,7 @@ function conflictText(conflict) { return `${courseCode(conflict.a)} and ${courseCode(conflict.b)} overlap on ${days}.`; } -export function renderSchedule(items, term, { calendar, onRemove, onClear, onShare, onChoose }) { +export function renderSchedule(items, term, { calendar, onRemove, onClear, onShare, onChoose, onGenerate }) { const current = items.filter((item) => String(item.term) === String(term)); const wrap = el("section", "plan"); @@ -152,6 +152,12 @@ export function renderSchedule(items, term, { calendar, onRemove, onClear, onSha head.append(heading); const actions = el("div", "plan-actions"); + if (onGenerate) { + const generate = el("button", "plan-action plan-generate", "Build schedule"); + generate.type = "button"; + generate.addEventListener("click", onGenerate); + actions.append(generate); + } const share = el("button", "plan-action", "Share"); share.type = "button"; share.disabled = !current.length; diff --git a/tests/app-generator.test.js b/tests/app-generator.test.js new file mode 100644 index 0000000..cfa8765 --- /dev/null +++ b/tests/app-generator.test.js @@ -0,0 +1,59 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { mountApp, fire, until } from "./dom.js"; +import { stubFetch } from "./helpers.js"; +import { RATINGS, SEATS_INDEX, SEATS_TERMS, entry, taught } from "./fixtures.js"; + +const TERM = "1268"; +const CSE = entry("CSE", "2221", "Software 1", [ + taught(1101, ["monday", "wednesday"], "10:00 AM", "10:55 AM", ["Paolo Bucci"]), + taught(1102, ["monday", "wednesday"], "1:00 PM", "1:55 PM", ["Paolo Bucci"]), +]); +const MATH = entry("MATH", "1151", "Calculus 1", [ + taught(2001, ["tuesday", "thursday"], "10:00 AM", "10:55 AM", ["Diana Ikenberry Kline"]), + taught(2002, ["monday", "wednesday"], "10:00 AM", "10:55 AM", ["Diana Ikenberry Kline"]), +]); + +function serve() { + return stubFetch([ + [/searchableTermsV2/, { data: { data: [{ strm: TERM, descr: "Autumn 2026" }] } }], + [/\/classes\/search/, (raw) => { + const url = new URL(raw); + const subject = url.searchParams.get("subject")?.toUpperCase(); + const courses = subject === "CSE" ? [CSE] : subject === "MATH" ? [MATH] : []; + return { data: { totalItems: courses.length, totalPages: 1, courses } }; + }], + ["data/ratings.json", RATINGS], + ["data/seats.json", SEATS_INDEX], + [`data/seats-${TERM}.json`, SEATS_TERMS[TERM]], + ["data/grades.json", { ok: false, status: 404, json: async () => null }], + ]); +} + +test("the schedule view generates and adopts a ranked multi-course schedule", async (t) => { + t.after(serve()); + const page = await mountApp(); + await until(() => page.el("#term").options.length, "the term list"); + + page.el("#view-schedule").click(); + page.el(".plan-generate").click(); + assert.match(page.el(".generator").textContent, /Build a conflict-free schedule/); + + for (const code of ["CSE 2221", "MATH 1151"]) { + page.el("#gen-course").value = code; + fire(page.el(".gen-add"), "submit"); + } + assert.equal(page.all(".gen-course-chip").length, 2); + + fire(page.el(".gen-preferences"), "submit"); + await until(() => page.el(".gen-result"), "a generated schedule"); + assert.match(page.el("#status").textContent, /conflict-free schedules/); + assert.doesNotMatch(page.el(".gen-result").textContent, /Sections 1101.*Sections 2002/s, + "the overlapping pair is not offered together"); + + page.el(".gen-use").click(); + assert.equal(page.all(".plan-item").length, 2); + assert.equal(page.el("#schedule-count").textContent, "2"); + assert.match(page.el("#status").textContent, /Using generated schedule with 2 courses/); +}); diff --git a/tests/generator-render.test.js b/tests/generator-render.test.js new file mode 100644 index 0000000..7f11301 --- /dev/null +++ b/tests/generator-render.test.js @@ -0,0 +1,59 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { setupDom, fire } from "./dom.js"; +import { renderGenerator } from "../js/generator-render.js"; +import { entry, taught } from "./fixtures.js"; + +function options(overrides = {}) { + return { + desired: ["CSE 2221", "MATH 1151"], + preferences: { earliest: "09:00", latest: "17:00", excludeFull: true, weights: {} }, + results: [], phase: "idle", note: "", + onBack() {}, onAdd() { return true; }, onRemove() {}, onGenerate() {}, onUse() {}, + ...overrides, + }; +} + +test("the builder exposes desired courses, constraints and all ranking priorities", () => { + const page = setupDom(); + const view = renderGenerator(options()); + page.document.body.append(view); + assert.equal(view.querySelectorAll(".gen-course-chip").length, 2); + assert.equal(view.querySelectorAll(".gen-weight input").length, 7); + assert.match(view.textContent, /Open seats/); + assert.match(view.textContent, /Grade history/); + assert.match(view.textContent, /Shorter gaps/); + assert.equal(view.querySelector(".gen-generate").disabled, false); +}); + +test("submitting preferences returns the visible bounds and weights", () => { + const page = setupDom(); + let received = null; + const view = renderGenerator(options({ onGenerate: (value) => { received = value; } })); + page.document.body.append(view); + fire(view.querySelector(".gen-preferences"), "submit"); + assert.equal(received.earliest, "09:00"); + assert.equal(received.latest, "17:00"); + assert.equal(received.excludeFull, true); + assert.equal(received.weights.seats, 1); + assert.equal(Object.keys(received.weights).length, 7); +}); + +test("a ranked result explains its evidence and can be adopted", () => { + const page = setupDom(); + const cse = entry("CSE", "2221", "Software 1", [taught(1001, ["monday"], "10:00 AM", "10:55 AM", ["Ada Teacher"])]); + const result = { + score: 84.4, + packages: [{ course: cse.course, sections: cse.sections }], + metrics: { minSeats: 8, rating: 4.2, gpa: 3.5, days: 3, gaps: 55, averageStart: 600, averageEnd: 900 }, + }; + let used = null; + const view = renderGenerator(options({ results: [result], onUse: (value) => { used = value; } })); + page.document.body.append(view); + assert.match(view.querySelector(".gen-result").textContent, /84 match/); + assert.match(view.querySelector(".gen-result").textContent, /8minimum seats/); + assert.match(view.querySelector(".gen-result").textContent, /Sections 1001/); + view.querySelector(".gen-use").click(); + assert.equal(used, result); +}); diff --git a/tests/generator.test.js b/tests/generator.test.js new file mode 100644 index 0000000..7f253cb --- /dev/null +++ b/tests/generator.test.js @@ -0,0 +1,171 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { describePackage, expandCoursePackages, generateSchedules, scoreCombination } from "../js/generator.js"; +import { entry, taught } from "./fixtures.js"; + +const TERM = "1268"; + +function linked(rows) { + return (number) => rows[String(number)] ?? null; +} + +function providers(seats = {}, ratings = {}, grades = {}) { + return { + seatsFor: (number) => seats[String(number)] ?? null, + ratingFor: (name) => ratings[name] ?? null, + gradesFor: (person) => grades[person.name] ?? null, + }; +} + +test("linked lectures and recitations become complete deduplicated packages", () => { + const found = entry("CSE", "2221", "Software 1", [ + taught(1001, ["monday"], "9:00 AM", "9:55 AM", ["Ada Teacher"]), + taught(1011, ["tuesday"], "9:00 AM", "9:55 AM", ["Ada Teacher"], { component: "Recitation" }), + taught(1012, ["tuesday"], "10:00 AM", "10:55 AM", ["Ada Teacher"], { component: "Recitation" }), + ]); + const result = expandCoursePackages(found, TERM, linked({ + 1001: { enrolledBy: ["1011", "1012"], enrolls: [] }, + 1011: { enrolls: ["1001"], enrolledBy: [] }, + 1012: { enrolls: ["1001"], enrolledBy: [] }, + })); + + assert.deepEqual(result.missing, []); + assert.deepEqual(result.packages.map((pkg) => pkg.sections.map((section) => section.classNumber)), [ + [1001, 1011], + [1001, 1012], + ]); +}); + +test("a shared child satisfies both required parents", () => { + const found = entry("CHEM", "1110", "Chemistry", [ + taught(2001, ["monday"], "9:00 AM", "9:55 AM", ["Ada Teacher"]), + taught(2002, ["wednesday"], "9:00 AM", "9:55 AM", ["Ada Teacher"]), + taught(2010, ["friday"], "9:00 AM", "9:55 AM", ["Ada Teacher"], { component: "Laboratory" }), + ]); + const result = expandCoursePackages(found, TERM, linked({ + 2001: { enrolledBy: ["2010"], enrolls: [] }, + 2002: { enrolledBy: ["2010"], enrolls: [] }, + 2010: { enrolls: ["2001", "2002"], enrolledBy: [] }, + })); + assert.equal(result.packages.length, 1); + assert.deepEqual(result.packages[0].sections.map((section) => section.classNumber).sort(), [2001, 2002, 2010]); +}); + +test("missing linked sections are reported and incomplete packages are omitted", () => { + const found = entry("CSE", "2221", "Software 1", [ + taught(1001, ["monday"], "9:00 AM", "9:55 AM", ["Ada Teacher"]), + ]); + const result = expandCoursePackages(found, TERM, linked({ + 1001: { enrolledBy: ["1011"], enrolls: [] }, + })); + assert.deepEqual(result.packages, []); + assert.deepEqual(result.missing, ["1011"]); +}); + +test("package evidence uses the scarcest linked section and ignores thin ratings", () => { + const found = entry("CSE", "2221", "Software 1", [ + taught(1001, ["monday"], "9:00 AM", "9:55 AM", ["Ada Teacher"]), + taught(1011, ["tuesday"], "10:00 AM", "10:55 AM", ["Thin Teacher"], { component: "Laboratory" }), + ]); + const pkg = describePackage({ course: found.course, sections: found.sections }, TERM, providers( + { 1001: { enrolled: 30, limit: 40 }, 1011: { enrolled: 19, limit: 20 } }, + { "Ada Teacher": { avgRating: 4.5, numRatings: 20 }, "Thin Teacher": { avgRating: 5, numRatings: 1 } }, + { "Ada Teacher": { gpa: 3.4, n: 80 } }, + )); + assert.equal(pkg.minSeats, 1); + assert.equal(pkg.hasFull, false); + assert.equal(pkg.rating, 4.5); + assert.equal(pkg.gpa, 3.4); + assert.equal(pkg.ranges.length, 2); +}); + +test("a known full linked section is excluded even when another has unknown seats", () => { + const found = entry("CSE", "2221", "Software 1", [ + taught(1001, ["monday"], "9:00 AM", "9:55 AM", ["Ada Teacher"]), + taught(1011, ["tuesday"], "10:00 AM", "10:55 AM", ["Ada Teacher"], { component: "Laboratory" }), + ]); + const pkg = describePackage({ course: found.course, sections: found.sections }, TERM, providers({ + 1011: { enrolled: 20, limit: 20, full: true }, + })); + assert.equal(pkg.minSeats, null); + assert.equal(pkg.hasFull, true); + assert.equal(generateSchedules([{ course: found.course, packages: [pkg] }]).results.length, 0); +}); + +test("a registration package whose own linked meetings overlap is invalid", () => { + const a = candidate("CSE", "2221", 1001, ["monday"], "9:00", "10:00"); + const b = candidate("CSE", "2221", 1011, ["monday"], "9:00", "10:00"); + const pkg = { ...a, sections: [...a.sections, ...b.sections], ranges: [...a.ranges, ...b.ranges] }; + assert.equal(generateSchedules([{ course: a.course, packages: [pkg] }]).results.length, 0); +}); + +function candidate(subject, number, classNumber, days, start, end, metrics = {}) { + const found = entry(subject, number, subject, [taught(classNumber, days, start, end, ["Ada Teacher"])]); + return { + course: found.course, + sections: found.sections, + ranges: found.sections.flatMap((section) => section.meetings.flatMap((meeting) => days.map((day) => ({ + day, + start: Number(start.split(":")[0]) * 60, + end: Number(end.split(":")[0]) * 60, + classNumber: String(classNumber), + })))), + minSeats: metrics.minSeats ?? 5, + rating: metrics.rating ?? 4, + gpa: metrics.gpa ?? 3, + }; +} + +test("generation rejects course conflicts, busy periods and full packages", () => { + const cseMorning = candidate("CSE", "2221", 1001, ["monday"], "9:00", "10:00"); + const cseLate = candidate("CSE", "2221", 1002, ["monday"], "11:00", "12:00"); + const mathConflict = candidate("MATH", "1151", 2001, ["monday"], "9:00", "10:00"); + const mathOpen = candidate("MATH", "1151", 2002, ["tuesday"], "11:00", "12:00"); + const mathFull = candidate("MATH", "1151", 2003, ["wednesday"], "11:00", "12:00", { minSeats: 0 }); + + const generated = generateSchedules([ + { course: cseMorning.course, packages: [cseMorning, cseLate] }, + { course: mathOpen.course, packages: [mathConflict, mathOpen, mathFull] }, + ], { busy: [{ days: ["monday"], start: 11 * 60, end: 12 * 60 }] }); + + assert.equal(generated.truncated, false); + assert.equal(generated.results.length, 1); + assert.deepEqual(generated.results[0].packages.map((pkg) => pkg.sections[0].classNumber).sort(), [1001, 2002]); +}); + +test("earliest and latest bounds apply to every linked meeting", () => { + const early = candidate("CSE", "2221", 1001, ["monday"], "8:00", "9:00"); + const middle = candidate("CSE", "2221", 1002, ["monday"], "10:00", "11:00"); + const late = candidate("CSE", "2221", 1003, ["monday"], "5:00", "6:00"); + // The helper uses hour values directly; replace the afternoon ranges to keep + // the fixture terse without teaching it am/pm parsing. + late.ranges = [{ day: "monday", start: 17 * 60, end: 18 * 60, classNumber: "1003" }]; + const generated = generateSchedules([ + { course: middle.course, packages: [early, middle, late] }, + ], { earliest: 9 * 60, latest: 17 * 60 }); + assert.deepEqual(generated.results.map((result) => result.packages[0].sections[0].classNumber), [1002]); +}); + +test("ranking rewards seats, evidence and compact schedules", () => { + const strong = [ + candidate("CSE", "2221", 1001, ["monday"], "10:00", "11:00", { minSeats: 10, rating: 4.5, gpa: 3.7 }), + candidate("MATH", "1151", 2001, ["monday"], "11:00", "12:00", { minSeats: 8, rating: 4.2, gpa: 3.5 }), + ]; + const weak = [ + candidate("CSE", "2221", 1002, ["monday"], "8:00", "9:00", { minSeats: 1, rating: 2.5, gpa: 2.2 }), + candidate("MATH", "1151", 2002, ["friday"], "4:00", "5:00", { minSeats: 1, rating: 2.8, gpa: 2.4 }), + ]; + assert.ok(scoreCombination(strong).score > scoreCombination(weak).score); + assert.equal(scoreCombination(strong).metrics.days, 1); + assert.equal(scoreCombination(strong).metrics.gaps, 0); +}); + +test("generation reports when its work budget truncates the search", () => { + const groups = ["CSE", "MATH", "STAT"].map((subject, i) => ({ + course: { subject, catalogNumber: "1000" }, + packages: [0, 1, 2].map((n) => candidate(subject, "1000", i * 10 + n, ["monday"], `${8 + n}:00`, `${9 + n}:00`)), + })); + const generated = generateSchedules(groups, { maxVisited: 2 }); + assert.equal(generated.truncated, true); +});