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
54 changes: 51 additions & 3 deletions css/finder.css
Original file line number Diff line number Diff line change
Expand Up @@ -1064,13 +1064,37 @@ body {

.gen-add {
display: grid;
grid-template-columns: minmax(10rem, 20rem) auto;
grid-template-columns: minmax(10rem, 20rem) max-content;
align-items: end;
gap: 0.45rem;
margin-bottom: 0.65rem;
}

.gen-add .f-label { grid-column: 1 / -1; margin: 0; }
.gen-input-wrap { position: relative; }
.gen-input-wrap > .f-ctl { width: 100%; }

.gen-suggestions {
position: absolute;
z-index: 8;
top: calc(100% + 0.2rem);
right: 0;
left: 0;
margin: 0;
padding: 0.25rem;
border: 1px solid var(--field);
border-radius: var(--radius);
background: var(--paper);
box-shadow: 0 0.45rem 1rem rgb(0 0 0 / 12%);
list-style: none;
}

.gen-suggestions[hidden] { display: none; }
.gen-suggestion { display: grid; width: 100%; padding: 0.45rem; border: 0; border-radius: 0.2rem; background: transparent; color: var(--ink); text-align: left; cursor: pointer; }
.gen-suggestion:hover,
.gen-suggestion:focus-visible { background: var(--wash); }
.gen-suggestion strong { font-family: var(--data); font-size: 0.8rem; }
.gen-suggestion span { color: var(--mute); font-size: 0.75rem; }

.gen-add-button,
.gen-generate,
Expand Down Expand Up @@ -1138,6 +1162,24 @@ body {
font-size: 0.8rem;
}

.gen-help { color: var(--mute); cursor: help; font-size: 0.76rem; }
.gen-help:focus-visible { outline: 2px solid var(--scarlet); outline-offset: 2px; }

.gen-modes {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.2rem 1rem;
margin: 0.9rem 0 0;
padding: 0.7rem 0 0;
border: 0;
border-top: 1px solid var(--rule);
}

.gen-modes .f-label { width: 100%; margin: 0; }
.gen-modes .gen-check { min-height: 1.8rem; }
.gen-mode-note { width: 100%; color: var(--mute); font-size: 0.72rem; }

.gen-weights {
display: grid;
grid-template-columns: repeat(2, minmax(12rem, 1fr));
Expand All @@ -1147,14 +1189,15 @@ body {

.gen-weight {
display: grid;
grid-template-columns: minmax(8rem, 1fr) minmax(6rem, 1fr);
grid-template-columns: minmax(9rem, 1fr) minmax(6rem, 1fr) 5.5rem;
align-items: center;
gap: 0.6rem;
color: var(--mute);
font-size: 0.78rem;
}

.gen-weight input { width: 100%; accent-color: var(--scarlet); }
.gen-weight-value { color: var(--ink); font-size: 0.7rem; text-align: right; }
.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; }
Expand All @@ -1168,12 +1211,13 @@ body {
}

.gen-result:first-of-type { border-color: var(--open); }
.gen-factors { margin: 0.25rem 0 0; color: var(--mute); font-size: 0.72rem; }
.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));
grid-template-columns: repeat(4, minmax(5.5rem, 1fr));
gap: 0.6rem;
margin: 0.9rem 0;
}
Expand All @@ -1185,6 +1229,10 @@ body {
.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; }
.gen-section-name { display: flex; align-items: center; gap: 0.4rem; }
.gen-pin { padding: 0.12rem 0.35rem; border: 1px solid var(--field); border-radius: 999px; background: transparent; color: var(--mute); font: inherit; font-size: 0.68rem; cursor: pointer; }
.gen-pin:hover,
.gen-pin.is-pinned { border-color: var(--scarlet); color: var(--scarlet); }

@media (max-width: 46rem) {
.gen-head { display: block; }
Expand Down
121 changes: 103 additions & 18 deletions js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { fetchTerms, defaultTerm, searchAllPages, GEN_CATEGORIES, ApiError } fro
import { filterCourses, parseQuery } from "./rank.js";
import { renderResults } from "./render.js";
import { loadRatings, loadRatingCourses, topRated, ratedCount, profileUrl, ratingFor, ratingsFailed } from "./ratings.js";
import { gradesFor, loadGrades } from "./grades.js";
import { 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";
Expand All @@ -11,14 +11,16 @@ import { applyFilters, hiddenFor, isActive, parseBusy, formatBusy, DEFAULTS } fr
import { busyLabel } from "./format.js";
import { renderCalendar } from "./calendar.js";
import { formatCoverage } from "./format.js";
import { loadCourses, subjectsFor, subjectLabel, coursesFor, codeFromInput, isLoaded } from "./courses.js";
import { loadCourses, subjectsFor, subjectLabel, coursesFor, courseSuggestions, codeFromInput, isLoaded } from "./courses.js";
import { classFromParams, setClassParam, sameSearch, hasSection, missOutcome } from "./deeplink.js";
import { isSortKey, sortEntries, unknownSections } from "./sort.js";
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 {
DEFAULT_GENERATOR_WEIGHTS, INSTRUCTION_MODES, describePackage, expandCoursePackages, generateSchedules, packageKey,
} from "./generator.js";
import { renderGenerator } from "./generator-render.js";

const els = {
Expand Down Expand Up @@ -92,7 +94,11 @@ let schedule = loadSchedule();
let scheduleNote = "";
let generator = {
desired: [],
preferences: { earliest: "", latest: "", excludeFull: true, weights: { ...DEFAULT_GENERATOR_WEIGHTS } },
preferences: {
earliest: "", latest: "", excludeFull: true,
allowedModes: [...INSTRUCTION_MODES], weights: { ...DEFAULT_GENERATOR_WEIGHTS },
},
pins: {},
results: [],
phase: "idle",
note: "",
Expand Down Expand Up @@ -589,24 +595,49 @@ function normalizeCourseCode(raw) {
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.";
async function generatorSuggestions(raw) {
if (!String(raw ?? "").trim()) return [];
await loadCourses();
return courseSuggestions(els.term.value, raw);
}

async function addDesiredCourse(raw) {
let choice = typeof raw === "object" && raw ? raw : null;
if (!choice) {
const code = normalizeCourseCode(raw);
if (!code) {
generator.note = "Type part of a course number or title, then choose a course.";
generator.phase = "error";
paintGenerator();
return false;
}
try {
const suggestions = await generatorSuggestions(code);
choice = suggestions.find((candidate) => candidate.key === code || candidate.code === code) ?? null;
} catch {
generator.note = "Could not load the course list. Try again.";
generator.phase = "error";
paintGenerator();
return false;
}
}
if (!choice) {
generator.note = "Choose an offered course from the suggestions.";
generator.phase = "error";
paintGenerator();
return false;
}
if (!generator.desired.includes(code)) generator.desired.push(code);
if (!generator.desired.some((candidate) => candidate.key === choice.key)) generator.desired.push(choice);
generator.results = [];
generator.phase = "idle";
generator.note = "";
paintGenerator();
return true;
}

function removeDesiredCourse(code) {
generator.desired = generator.desired.filter((candidate) => candidate !== code);
function removeDesiredCourse(key) {
generator.desired = generator.desired.filter((candidate) => candidate.key !== key);
delete generator.pins[key];
generator.results = [];
generator.phase = "idle";
generator.note = "";
Expand All @@ -621,7 +652,7 @@ function exactCourse(courses, code) {
) ?? null;
}

async function coursePackages(code, term) {
async function packagesForExactCourse(code, term) {
const result = await searchAllPages({ q: code, term });
let entry = exactCourse(result.courses, code);
if (!entry) return { code, packages: [], missingCourse: true, missingLinked: [] };
Expand All @@ -648,12 +679,56 @@ async function coursePackages(code, term) {

return {
code,
packages: expanded.packages.map((pkg) => describePackage(pkg, term, { seatsFor, ratingFor, gradesFor })),
packages: expanded.packages.map((pkg) => describePackage(pkg, term, { seatsFor, ratingFor })),
missingCourse: false,
missingLinked: expanded.missing,
};
}

async function coursePackages(choice, term) {
const variants = await Promise.all(choice.codes.map((code) => packagesForExactCourse(code, term)));
const packages = variants.flatMap((variant) => variant.packages).map((pkg) => ({
...pkg,
desiredKey: choice.key,
desiredLabel: choice.family ? `${choice.code} (any version)` : choice.code,
packageKey: packageKey(pkg.sections),
}));
return {
key: choice.key,
label: choice.family ? `${choice.code} (any version)` : choice.code,
course: packages[0]?.course,
packages,
missingCourse: variants.every((variant) => variant.missingCourse),
missingLinked: choice.family && packages.length
? []
: [...new Set(variants.flatMap((variant) => variant.missingLinked))],
};
}

function generatorFailureNote(diagnostics) {
const blocked = diagnostics?.blockedGroups?.[0];
if (blocked) {
const reasons = Object.entries(blocked.removed).sort((a, b) => b[1] - a[1]);
const reason = reasons[0]?.[1] ? reasons[0][0] : "unknown";
const messages = {
full: "all of its registration choices are full",
internal: "its linked sections overlap each other",
time: "all choices fall outside your time limits",
busy: "all choices overlap a busy period",
mode: "all choices use an excluded instruction mode",
pin: "the pinned section is no longer available",
unknown: "it has no complete registration choices",
};
return `${blocked.label} cannot be scheduled because ${messages[reason]}.`;
}
const conflict = diagnostics?.conflicts?.[0];
if (conflict) {
const days = conflict.days.map((day) => day[0].toUpperCase() + day.slice(1)).join(" and ");
return `${conflict.a} conflicts with ${conflict.b}${days ? ` on ${days}` : ""}. Try another course, change a pin, or widen your constraints.`;
}
return "No conflict-free schedule fits these courses and constraints. Try widening the time window, allowing full classes, or changing a busy period.";
}

async function runGenerator(preferences) {
generator.preferences = preferences;
generator.phase = "loading";
Expand All @@ -667,17 +742,16 @@ async function runGenerator(preferences) {
await Promise.all([
loadSeats(term),
loadRatings().catch(() => null),
loadGrades().catch(() => null),
]);
const groups = await Promise.all(generator.desired.map((code) => coursePackages(code, term)));
const groups = await Promise.all(generator.desired.map((choice) => coursePackages(choice, term)));
if (request !== generatorRequest) return;

const missing = groups.filter((group) => group.missingCourse).map((group) => group.code);
const missing = groups.filter((group) => group.missingCourse).map((group) => group.label);
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(", ")}.`);
if (incomplete.length) notes.push(`Could not resolve every linked section for ${incomplete.map((group) => group.label).join(", ")}.`);
generator.phase = "error";
generator.note = notes.join(" ");
paintGenerator();
Expand All @@ -690,13 +764,15 @@ async function runGenerator(preferences) {
earliest: fieldMinutes(preferences.earliest),
latest: fieldMinutes(preferences.latest),
excludeFull: preferences.excludeFull,
allowedModes: preferences.allowedModes,
pins: generator.pins,
});
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.";
: generatorFailureNote(generated.diagnostics);
paintGenerator();
} catch (error) {
if (request !== generatorRequest) return;
Expand All @@ -723,6 +799,13 @@ function useGeneratedSchedule(result, term = els.term.value) {
els.results.focus();
}

function toggleGeneratorPin(pkg) {
const key = pkg.desiredKey ?? `${pkg.course.subject} ${pkg.course.catalogNumber}`;
if (generator.pins[key] === pkg.packageKey) delete generator.pins[key];
else generator.pins[key] = pkg.packageKey;
runGenerator(generator.preferences);
}

function paintGenerator(term = els.term.value) {
view = "generator";
els.welcome.hidden = true;
Expand All @@ -731,8 +814,10 @@ function paintGenerator(term = els.term.value) {
onBack: () => setView("schedule"),
onAdd: addDesiredCourse,
onRemove: removeDesiredCourse,
onSuggest: generatorSuggestions,
onGenerate: runGenerator,
onUse: (result) => useGeneratedSchedule(result, term),
onPin: toggleGeneratorPin,
}));
resetDetail();
els.app.dataset.view = "results";
Expand Down
Loading
Loading