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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,12 @@ The Ohio State address in that picture does not. It sends one header,
`Access-Control-Allow-Origin: *`, which is standing permission for any page
to read it. That header is why Finder can exist with no server.

Barrett's file also carries a compact instructor listing, and it can land
there before the primary instructor appears in Ohio State's class API. The
nightly snapshot keeps that fourth field beside the seat counts. Finder uses
it only when the live response has no primary instructor and gives the live
Ohio State assignment precedence as soon as it appears.

### OSU's API reports enrollment wrong per section

OSU's class API returns one enrollment number per course and stamps it onto
Expand Down
2 changes: 1 addition & 1 deletion data/seats-1268.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion data/seats-1272.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion data/seats.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"source":"https://www.asc.ohio-state.edu/barrett.3/schedule/","fields":["enrolled","limit","waitlist"],"note":"Barrett rebuilds a live term once a day around 06:50 Eastern and freezes a term once it is over, so sourceUpdated differs per term. A missing class number means unknown, not zero.","terms":[{"term":"1264","termName":"Summer 2026","sourceUpdated":"2026-07-29","sections":4866,"file":"seats-1264.json"},{"term":"1268","termName":"Autumn 2026","sourceUpdated":"2026-09-16","sections":17753,"file":"seats-1268.json"},{"term":"1272","termName":"Spring 2027","sourceUpdated":"2026-09-16","sections":16579,"file":"seats-1272.json"}]}
{"source":"https://www.asc.ohio-state.edu/barrett.3/schedule/","fields":["enrolled","limit","waitlist","instructor"],"note":"Barrett rebuilds a live term once a day around 06:50 Eastern and freezes a term once it is over, so sourceUpdated differs per term. Instructor is Barrett's compact listing and is used only when Ohio State has not published a primary instructor. A missing class number means unknown, not zero.","terms":[{"term":"1264","termName":"Summer 2026","sourceUpdated":"2026-07-29","sections":4866,"file":"seats-1264.json"},{"term":"1268","termName":"Autumn 2026","sourceUpdated":"2026-09-16","sections":17753,"file":"seats-1268.json"},{"term":"1272","termName":"Spring 2027","sourceUpdated":"2026-09-16","sections":16579,"file":"seats-1272.json"}]}
7 changes: 5 additions & 2 deletions js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ 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 } from "./seats.js";
import { linkedTo, loadSeats, seatsTerm, seatsUpdated, seatsSectionCount, seatsFailed, withSeatInstructors } from "./seats.js";
import { loadTrend } from "./trend.js";
import { renderDetail } from "./detail.js";
import { loadHeadshots } from "./headshots.js";
Expand Down Expand Up @@ -840,7 +840,10 @@ async function runSearch(q, term, subject, gen = genCategory()) {
loadTrend(term),
]);
if (requestId !== latestRequest) return; // a newer search already answered
lastResult = { ...filterCourses(courses, q), totalItems, term };
// Barrett's nightly file can name an instructor before the live class API
// does. It is already awaited above for seats, so applying the fallback
// adds no request to a course search.
lastResult = { ...filterCourses(withSeatInstructors(courses, term), q), totalItems, term };
lastQuery = q.trim();
paint(term);
} catch (error) {
Expand Down
14 changes: 11 additions & 3 deletions js/format.js
Original file line number Diff line number Diff line change
Expand Up @@ -107,10 +107,18 @@ export function trendLabel(trend) {
/** Instructors for a section, deduped, since they hang off each meeting. */
export function instructorsOf(section) {
const seen = new Map();
for (const meeting of section?.meetings ?? []) {
for (const person of meeting?.instructors ?? []) {
const groups = [
...(section?.meetings ?? []).map((meeting) => meeting?.instructors ?? []),
section?.fallbackInstructors ?? [],
];
for (const people of groups) {
for (const person of people) {
const name = person?.displayName?.trim();
if (name && !seen.has(name)) seen.set(name, { name, email: person.email ?? null, role: person.role ?? null });
if (name && !seen.has(name)) {
const found = { name, email: person.email ?? null, role: person.role ?? null };
if (person.source) found.source = person.source;
seen.set(name, found);
}
}
}
return [...seen.values()];
Expand Down
56 changes: 56 additions & 0 deletions js/seats.js
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,62 @@ export function seatsFor(classNumber, term) {
};
}

/**
* The instructor listing Barrett publishes for one section, or an empty list.
*
* Barrett writes compact names such as `P.Bucci` and can list several with a
* comma. A parenthesized suffix is its role: TA and graduate assistants are
* not promoted to primary instructors. Old snapshots have only three fields,
* so this naturally reads as absent until the nightly refresh writes the new
* column.
*/
export function instructorsFor(classNumber, term) {
if (!term) return [];
const raw = loaded.get(String(term))?.sections?.[String(classNumber)]?.[3];
if (typeof raw !== "string" || !raw.trim()) return [];

return raw.split(/,\s+/).map((listing) => {
const match = /^(.+?)(?:\s+\(([^)]+)\))?$/.exec(listing.trim());
const compact = match?.[1]?.trim() ?? "";
if (!compact) return null;
const dot = compact.indexOf(".");
const name = dot > 0 ? `${compact.slice(0, dot + 1)} ${compact.slice(dot + 1)}` : compact;
const code = match?.[2]?.trim().toUpperCase() ?? "";
return {
displayName: name,
email: null,
// An unqualified name is the primary listing. Preserve every explicit
// Barrett role (TA, SI, GR, GY, LA, GS...) so none is accidentally
// promoted merely because a new code appeared upstream.
role: code || "PI",
source: "barrett",
};
}).filter(Boolean);
}

/**
* Add Barrett's earlier instructor assignment to sections for which Ohio
* State has not published a primary instructor yet. The fallback lives on the
* section instead of being spliced into a fake meeting, so it cannot invent a
* time or room. Returns new entries and sections; the API response stays
* untouched for cached searches and tests.
*/
export function withSeatInstructors(entries, term) {
return (entries ?? []).map((entry) => ({
...entry,
sections: (entry.sections ?? []).map((section) => {
const apiPeople = (section.meetings ?? []).flatMap((meeting) => meeting?.instructors ?? []);
if (apiPeople.some((person) => person?.role === "PI")) return section;
// This feature fills the professor assignment. Barrett also names TAs
// and graduate assistants, but adding those beside an API-published TA
// can duplicate the same person in compact and full-name forms.
const fallbackInstructors = instructorsFor(section.classNumber, term)
.filter((person) => person.role === "PI");
return fallbackInstructors.length ? { ...section, fallbackInstructors } : section;
}),
}));
}

// Both directions of a term's packages, built once per term. The snapshot
// stores each one parent first, which is where the direction lives. A section
// can sit under two parents, so the child side is a list.
Expand Down
25 changes: 21 additions & 4 deletions scripts/fetch-seats.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,17 @@ function parseDays(line) {
return out;
}

// The area after column 94 usually holds instructors, but short-session rows
// use it for markers such as {7W1}. Every instructor token Barrett publishes
// starts with an initial and a dot; require that shape for every comma-separated
// person so a session marker can never become a professor on screen.
function instructorListing(raw) {
const value = raw.trim();
if (!value) return '';
const people = value.split(/,\s+/);
return people.every((person) => /^[A-Za-z][A-Za-z'-]*\.[^\s].*/.test(person)) ? value : '';
}

// A file whose shape is not the one the column map describes is a layout
// change, not a stray line, so a caller that tolerates a few bad subjects has
// to rethrow this rather than count it as one of them.
Expand Down Expand Up @@ -271,7 +282,7 @@ function parseSubjectFile(subject, term, text) {
enrolled: Number(tail[2]),
limit: Number(tail[3]),
waitlist: tail[4] ? Number(tail[4]) : 0,
instructor: line.slice(INSTRUCTOR_START).trim(),
instructor: instructorListing(line.slice(INSTRUCTOR_START)),
});
}

Expand Down Expand Up @@ -401,7 +412,13 @@ async function snapshotTerm(term, subjects) {
const sections = {};
for (const key of [...byClass.keys()].sort((a, b) => Number(a) - Number(b))) {
const s = byClass.get(key);
sections[key] = [s.enrolled, s.limit, s.waitlist];
// Barrett sometimes publishes the instructor before Ohio State's class
// API does. Keep the raw listing beside the seat counts so the browser can
// use it only as a fallback; an empty fourth field costs nothing useful and
// old three-column snapshots remain readable.
sections[key] = s.instructor
? [s.enrolled, s.limit, s.waitlist, s.instructor]
: [s.enrolled, s.limit, s.waitlist];
}

const groups = linkGroups([...byClass.values()]);
Expand Down Expand Up @@ -694,8 +711,8 @@ async function main() {
// not publish from one it has simply not fetched yet.
const indexBytes = await writeJson(join(OUT_DIR, INDEX_NAME), {
source: `${BASE}/`,
fields: ['enrolled', 'limit', 'waitlist'],
note: 'Barrett rebuilds a live term once a day around 06:50 Eastern and freezes a term once it is over, so sourceUpdated differs per term. A missing class number means unknown, not zero.',
fields: ['enrolled', 'limit', 'waitlist', 'instructor'],
note: 'Barrett rebuilds a live term once a day around 06:50 Eastern and freezes a term once it is over, so sourceUpdated differs per term. Instructor is Barrett\'s compact listing and is used only when Ohio State has not published a primary instructor. A missing class number means unknown, not zero.',
terms: entries,
});
console.log(`wrote data/${INDEX_NAME} (${indexBytes} bytes)`);
Expand Down
11 changes: 8 additions & 3 deletions tests/contract.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ test("courses.json keeps its shape", async () => {
test("seats.json and the term files it lists agree", async () => {
const index = await read("seats.json");
assert.deepEqual(Object.keys(index), ["source", "fields", "note", "terms"]);
assert.deepEqual(index.fields, ["enrolled", "limit", "waitlist"]);
assert.deepEqual(index.fields, ["enrolled", "limit", "waitlist", "instructor"]);
assert.ok(index.terms.length > 0);

for (const entry of index.terms) {
Expand All @@ -141,8 +141,13 @@ test("seats.json and the term files it lists agree", async () => {
assert.equal(rows.length, entry.sections, `${entry.file} does not hold what the index says`);
for (const [classNumber, row] of rows) {
assert.match(classNumber, /^\d+$/);
assert.equal(row.length, 3, `${entry.file} ${classNumber} is not enrolled/limit/waitlist`);
for (const value of row) assert.equal(typeof value, "number");
assert.ok(row.length === 3 || row.length === 4,
`${entry.file} ${classNumber} is not enrolled/limit/waitlist[/instructor]`);
for (const value of row.slice(0, 3)) assert.equal(typeof value, "number");
if (row.length === 4) {
assert.equal(typeof row[3], "string");
assert.ok(row[3].trim(), `${entry.file} ${classNumber} has an empty instructor column`);
}
}
}
});
Expand Down
10 changes: 9 additions & 1 deletion tests/fetch-seats.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,14 @@ describe("where the column header is", () => {
assert.deepEqual(out.failures, []);
assert.equal(out.sections.length, 2);
});

test("a short-session marker is not stored as an instructor", () => {
const text = barrettFile("CSE", "1268", [
{ catalog: "2501", classNumber: "7099", enrolled: 0, limit: 5, instructor: "{7W2}" },
]);
const out = parseSubjectFile("CSE", "1268", text);
assert.equal(out.sections[0].instructor, "");
});
});

describe("what holds a term back", () => {
Expand Down Expand Up @@ -125,7 +133,7 @@ describe("what holds a term back", () => {
assert.equal(stats.sectionsParsed, 44, "the other 11 subjects still parsed");
assert.match(fetchErrors[0], /SUBJ07/);
assert.match(fetchErrors[0], /403/);
assert.deepEqual(snapshot.sections["10000"], [26, 40, 0], "SUBJ00 is in the snapshot");
assert.deepEqual(snapshot.sections["10000"], [26, 40, 0, "M.Mallon"], "SUBJ00 is in the snapshot");
assert.equal(snapshot.sections["10700"], undefined, "SUBJ07 is not, its sections read as unknown");
} finally {
restore();
Expand Down
6 changes: 3 additions & 3 deletions tests/fixtures.js
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ export function taught(classNumber, days, start, end, names, opts = {}) {
// one file per term, so the loader under test does the real two-step fetch.
export const SEATS_INDEX = {
source: "https://www.asc.ohio-state.edu/barrett.3/schedule/",
fields: ["enrolled", "limit", "waitlist"],
fields: ["enrolled", "limit", "waitlist", "instructor"],
note: "A missing class number means unknown, not zero.",
terms: [
{ term: "1262", termName: "Spring 2026", sourceUpdated: "2026-04-27", sections: 2, file: "seats-1262.json" },
Expand All @@ -83,8 +83,8 @@ export const SEATS_TERMS = {
"1268": {
term: "1268",
sections: {
"1001": [30, 40, 0], // open
"1002": [40, 40, 3], // exactly full, three waiting
"1001": [30, 40, 0, "P.Bucci"], // open, with Barrett's earlier instructor listing
"1002": [40, 40, 3, "S.Gomori, A.Meghrazi (TA), J.Weiler (SI)"], // exactly full, three waiting
"1003": [41, 40, 1], // over cap, which OSU's own API calls open
"1004": [0, 0, 1], // no published capacity, someone already waiting
"1005": [12], // malformed, too short
Expand Down
8 changes: 8 additions & 0 deletions tests/format.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,14 @@ test("instructorsOf survives missing meetings and missing instructors", () => {
assert.deepEqual(instructorsOf({ meetings: [{ startTime: "9:00 AM" }] }), []);
});

test("instructorsOf uses an early Barrett listing kept off the meeting", () => {
const people = instructorsOf({
meetings: [],
fallbackInstructors: [{ displayName: "P. Bucci", role: "PI", source: "barrett" }],
});
assert.deepEqual(people, [{ name: "P. Bucci", email: null, role: "PI", source: "barrett" }]);
});

// Regression, #82. CSE 2112 class 8823 comes back with ten meetings for one
// Tuesday class, alternating the room label between BE 120 and Baker Syst.
// Printing a line per meeting turned that row into nine repeats.
Expand Down
27 changes: 27 additions & 0 deletions tests/seats.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,33 @@ test("seatsFor reports an open section", () => {
assert.deepEqual(seats.seatsFor("1001", "1268"), { enrolled: 30, limit: 40, waitlist: 0, full: false });
});

test("Barrett instructors are parsed without changing seat reads", () => {
assert.deepEqual(seats.instructorsFor("1001", "1268"), [
{ displayName: "P. Bucci", email: null, role: "PI", source: "barrett" },
]);
assert.deepEqual(seats.instructorsFor("1002", "1268"), [
{ displayName: "S. Gomori", email: null, role: "PI", source: "barrett" },
{ displayName: "A. Meghrazi", email: null, role: "TA", source: "barrett" },
{ displayName: "J. Weiler", email: null, role: "SI", source: "barrett" },
]);
assert.deepEqual(seats.instructorsFor("1003", "1268"), [], "old three-column rows stay compatible");
});

test("Barrett fills an unpublished primary instructor without replacing Ohio State's", () => {
const missing = [{ course: {}, sections: [{ classNumber: "1001", meetings: [] }] }];
const [filled] = seats.withSeatInstructors(missing, "1268");
assert.equal(filled.sections[0].fallbackInstructors[0].displayName, "P. Bucci");
assert.notEqual(filled.sections[0], missing[0].sections[0], "the API response is not mutated");

const published = [{ course: {}, sections: [{
classNumber: "1001",
meetings: [{ instructors: [{ displayName: "Paolo Bucci", role: "PI" }] }],
}] }];
const [kept] = seats.withSeatInstructors(published, "1268");
assert.equal(kept.sections[0], published[0].sections[0]);
assert.equal(kept.sections[0].fallbackInstructors, undefined);
});

test("seatsFor calls a section at capacity full", () => {
const row = seats.seatsFor("1002", "1268");
assert.equal(row.full, true);
Expand Down
Loading