diff --git a/README.md b/README.md index 3e2f1f0..f0fb89e 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ Vite работает на http://127.0.0.1:5173 и проксирует `/api` - Живой каталог публичного Nextcloud университета: факультет, форма, курс и поиск по названию группы/файла. Проверенный каталог содержит 113 документов Word. - Расписание по дням или на всю неделю, поиск предмета, преподавателя и аудитории. -- Экран «Группы»: поиск группы по номеру в любом написании («23-БА», «23 ба», «23ба») по всем документам каталога, без учёта фильтров; одно нажатие открывает нужный документ и группу. Ниже — текущая группа с подгруппой и соседними группами документа, недавние группы (открываются и без сети) и каталог чипами: факультет → курс → форма обучения. Первый запуск начинается с этого экрана, а не с чьей-то группы (`/api/groups`). +- Экран «Группы»: поиск группы по номеру в любом написании («23-БА», «23 ба», «23ба») по всем документам каталога, без учёта фильтров; одно нажатие открывает нужный документ и группу. Ниже — текущая группа с подгруппой и соседними группами документа, недавние группы (открываются и без сети) и каталог чипами: факультет → курс → форма обучения. Первый запуск начинается с этого экрана, а не с чьей-то группы (`/api/groups`). Если у группы есть основное расписание, в выборе не показываются её расписание зачётов и экзаменационной сессии (они идут в обратный отсчёт сессии) и уже прошедшие документы с датами, например неделя «Введение в высшее образование». Группе, у которой есть только такой документ, он остаётся доступен. - Общие ячейки попадают в каждую соответствующую группу, чужие занятия отсекаются. - Отдельные карточки для альтернативных занятий и условий по неделям. Сохраняются исходный текст, цвет ячейки, подгруппы, даты и ограничения. - Учебные недели по таблице университета: переключатель недель с датами, показ только занятий выбранной недели, статус занятия («ещё 4 занятия · до 27 нояб.», «начнётся 20 нояб.») и сетка недель в деталях. Режим «Все строки Word» показывает документ как есть. diff --git a/server/directory.js b/server/directory.js index ced2d4d..8cc9fed 100644 --- a/server/directory.js +++ b/server/directory.js @@ -382,8 +382,26 @@ export function examsOf(idx, group, faculty) { export const groupKey = (s) => (s || "").toLowerCase().replace(/ё/g, "е").replace(/[\s\-–_.]/g, ""); -/** Every group of every document, with the catalog place it came from. */ -export function groupEntries(idx) { +/** + * Documents that only add to a group's timetable: their credits, exams and consultations reach + * the student through the session countdown (/api/exams), so the group picker offers them only + * when the group has nothing else. + */ +const SUPPLEMENTS = new Set(["Зачёты", "Экзаменационная сессия"]); + +/** The last date of a document whose every lesson is dated ("Введение в высшее образование"), else null. */ +function lastDate(schedule) { + const dates = schedule.lessons.flatMap((l) => (l.date ? [l.date] : l.dates || [])); + return dates.length && schedule.lessons.every((l) => l.date || l.dates?.length) + ? dates.sort().at(-1) + : null; +} + +/** + * Every group of every document, with the catalog place it came from. A group with a main + * timetable is not offered its supplements, nor dated documents that are already over. + */ +export function groupEntries(idx, today = todayIso()) { const list = []; for (const { file, schedule } of idx.docs) for (const group of schedule.groups) @@ -397,8 +415,11 @@ export function groupEntries(idx) { form: file.form || "", period: schedule.period || "", modified: file.modified || null, + over: (lastDate(schedule) ?? "9999") < today, }); - return list; + const secondary = (e) => SUPPLEMENTS.has(e.period) || e.over; + const main = new Set(list.filter((e) => !secondary(e)).map((e) => e.key)); + return list.filter((e) => !secondary(e) || !main.has(e.key)).map(({ over, ...e }) => e); } const count = (items, pick) => { @@ -416,8 +437,8 @@ const count = (items, pick) => { * Groups matching a query and/or catalog filters. The query ignores filters on * purpose: a student typing their group must find it in any faculty. */ -export function findGroups(idx, { q = "", faculty = "", course = "", form = "" } = {}) { - const all = groupEntries(idx); +export function findGroups(idx, { q = "", faculty = "", course = "", form = "", today } = {}) { + const all = groupEntries(idx, today); const key = groupKey(q); let results; if (key.length >= 2) { diff --git a/test/directory.test.js b/test/directory.test.js index c4dba09..400a770 100644 --- a/test/directory.test.js +++ b/test/directory.test.js @@ -166,3 +166,32 @@ test("Поиск группы: любое написание, без учёта assert.equal(findGroups(idx, {}).results.length, 0); assert.equal(findGroups(idx, {}).total, 4); }); + +test("Выбор группы: зачёты не дублируют группу с основным расписанием", async () => { + const { findGroups } = await import("../server/directory.js"); + const credits = parseDocx(fixture("isf-credits-26-arh"), { name: "26-АРХ.docx" }); + const main = { ...credits, period: "Осенний семестр" }; + const doc = (id, schedule) => ({ id, file: { id, name: "26-АРХ.docx", faculty: "ИСФ", course: "1 курс" }, schedule }); + // With a semester timetable the credits stay in the session countdown, not in the picker. + const both = findGroups({ docs: [doc("main", main), doc("credits", credits)] }, { q: "26-АРХ" }); + assert.deepEqual(both.results.map((r) => [r.group, r.fileId]), [["26-АРХ", "main"]]); + assert.equal(both.total, 1); + // A group with only a credit schedule can still open it. + const only = findGroups({ docs: [doc("credits", credits)] }, { q: "26-АРХ" }); + assert.deepEqual(only.results.map((r) => [r.group, r.fileId, r.period]), [["26-АРХ", "credits", "Зачёты"]]); +}); + +test("Выбор группы: прошедший документ с датами не дублирует основное расписание", async () => { + const { findGroups } = await import("../server/directory.js"); + // A document dated 8–27 June 2026, like an orientation week, beside the group's semester timetable. + const dated = parseDocx(fixture("itzs-summer-2026"), { name: "23-ИТзс.docx" }); + const main = { ...dated, period: "Осенний семестр", lessons: dated.lessons.map(({ date, ...l }) => l) }; + const doc = (id, schedule) => ({ id, file: { id, name: `${id}.docx` }, schedule }); + const idx = { docs: [doc("main", main), doc("dated", dated)] }; + const ids = (today) => findGroups(idx, { q: "23-ИТзс", today }).results.map((r) => r.fileId); + assert.deepEqual(ids("2026-10-19"), ["main"]); + // Before it is over, both are offered. + assert.deepEqual(ids("2026-06-20").sort(), ["dated", "main"]); + // Alone, even a finished one stays: it is all the group has. + assert.deepEqual(findGroups({ docs: [doc("dated", dated)] }, { q: "23-ИТзс", today: "2026-10-19" }).results.length, 1); +});