// 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", "Аудитория"], // "Полоцк": the town a cell or the day's note names (parser 14). ["place", "Город"], ["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 })); }