Files
psu-schedule/shared/lessons.js
Zakhar Timoshenko 10c85e3bb9 feat: read "Занятия в Полоцке" as a day's town, show it in all clients
- 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>
2026-10-21 01:26:54 +03:00

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 }));
}