Files
psu-schedule/shared/lessons.js
2026-10-16 22:08:45 +03:00

106 lines
4.1 KiB
JavaScript

// Lesson identity and schedule comparison, shared by the browser and the tests.
// Lesson ids depend on the table row, so they change whenever the university
// republishes a file; these keys describe what a student would call "the same lesson".
const norm = (s) => (s || "").toLowerCase().replace(/\s+/g, " ").trim();
const subgroupOf = (lesson, group) =>
lesson.subgroups?.find((s) => s.group === group)?.label || "";
/** Same day, time, subject, row colour and subgroup: survives republishing. */
export function lessonKey(lesson, group) {
return [
lesson.date || lesson.day,
lesson.start,
norm(lesson.subject),
lesson.variant,
subgroupOf(lesson, group),
norm(lesson.condition),
].join("|");
}
// Looser identity for pairing a lesson whose time or week mark changed.
const looseKey = (lesson, group) =>
[norm(lesson.subject), lesson.variant, subgroupOf(lesson, group), norm(lesson.type)].join("|");
const FIELDS = [
["start", "Начало"],
["end", "Окончание"],
["day", "День"],
["date", "Дата"],
["room", "Аудитория"],
["teacher", "Преподаватель"],
["condition", "Недели"],
["type", "Вид занятия"],
];
/**
* What changed for one group between two versions of the same document.
* Returns { added, removed, changed } where changed items list field differences.
*/
export function diffSchedules(before, after, group) {
const pick = (s) => (s?.lessons || []).filter((l) => l.groups.includes(group));
const removed = [],
added = [],
changed = [];
const old = new Map();
for (const lesson of pick(before)) {
const key = lessonKey(lesson, group);
old.set(key, [...(old.get(key) || []), lesson]);
}
const unmatched = [];
for (const lesson of pick(after)) {
const key = lessonKey(lesson, group),
list = old.get(key);
if (list?.length) {
const was = list.shift();
const fields = differences(was, lesson);
if (fields.length) changed.push({ before: was, after: lesson, fields });
} else unmatched.push(lesson);
}
const left = [...old.values()].flat();
// Pair the rest by subject on the same day first, then by subject alone (a lesson moved to another day).
for (const sameDay of [true, false])
for (const lesson of [...unmatched]) {
const index = left.findIndex(
(l) =>
looseKey(l, group) === looseKey(lesson, group) &&
(!sameDay || (l.date || l.day) === (lesson.date || lesson.day)),
);
if (index < 0) continue;
const [was] = left.splice(index, 1);
unmatched.splice(unmatched.indexOf(lesson), 1);
changed.push({ before: was, after: lesson, fields: differences(was, lesson) });
}
added.push(...unmatched);
removed.push(...left);
const order = (l) => `${l.date || ""}${l.day}${l.start}`;
added.sort((a, b) => order(a).localeCompare(order(b)));
removed.sort((a, b) => order(a).localeCompare(order(b)));
changed.sort((a, b) => order(a.after).localeCompare(order(b.after)));
return { added, removed, changed };
}
function differences(before, after) {
return FIELDS.filter(([field]) => (before[field] ?? "") !== (after[field] ?? "")).map(
([field, label]) => ({ field, label, from: before[field] ?? "", to: after[field] ?? "" }),
);
}
export const changeCount = (diff) =>
diff ? diff.added.length + diff.removed.length + diff.changed.length : 0;
/** Applies the student's own choices: their subgroup and the lessons they hid. */
export function personalFilter(lesson, group, prefs) {
const own = subgroupOf(lesson, group);
if (prefs?.subgroup && own && own !== prefs.subgroup) return "other-subgroup";
if (prefs?.hidden?.includes(lessonKey(lesson, group))) return "hidden";
return "shown";
}
/** Subgroup labels present for a group, in column order. */
export function subgroupsOf(schedule, group) {
const labels = new Set();
for (const lesson of schedule?.lessons || [])
if (lesson.groups.includes(group)) {
const label = subgroupOf(lesson, group);
if (label) labels.add(label);
}
return [...labels].sort((a, b) => a.localeCompare(b, "ru", { numeric: true }));
}