- Parser 14 (server/places.js): "Занятия в Полоцке/Новополоцке/г. Новополоцк" over a day's first row is no longer a lesson but a note of the day for its column (`places`); lessons of that day get `place`. A lesson below the note in the same cell keeps its own subject.
- A town on its own line ("Новополоцк", "г.Новополоцк") or after a room ("246(Новополоцк)") becomes the lesson's `place` and leaves the subject and room. "История Полоцка" is not a town.
- API 14: `place` on lessons and `places` on days of /api/timetable, `place` in the directory. The day notes are shared by the server and the PWA (shared/places.js); the same note in every subgroup reads as the whole group's.
- Android, PWA and iOS: a "Занятия в Полоцке" card above the day's lessons (day pages, calendar, overview, week view, desk stand), "Полоцк" under the date in the week grid, "249Ж · Полоцк" on lesson cards, a "Город" row in details, the town in widgets, tile, reminders and change lists.
- Snapshot lines show the town and the day notes; tests for notes, towns in cells and the API.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
108 lines
4.2 KiB
JavaScript
108 lines
4.2 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", "Аудитория"],
|
|
// "Полоцк": 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 }));
|
|
}
|