Files
schedule/src/frontend/static/app.js
Vladislav Kopnin fa51f4caee Аудитории: близкие типы объединены в фильтре, добавлен пункт «без типа»
В карточке остаётся формулировка из таблицы («комп.класс Linux»), а в фильтре
близкие варианты сведены к одной группе: «комп.класс» (комп.класс, комп.класс
Linux, компьютерный класс), «кабинет английского» (английский, кабинет
английского), «аудитория кафедры», «лекционная», «лекционно-практическая»,
«аудитория для практических». Добавлен пункт «без типа» — 4 аудитории, где в
таблице тип не указан.

Бэкенд отдаёт в /rooms-day и /rooms-free дополнительное поле type_group
(room_type_group), фронт фильтрует по нему. Тесты: 160.
2026-10-10 12:33:05 +03:00

827 lines
31 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const groupSelect = document.getElementById("group-select");
const entityLabel = document.getElementById("entity-label");
const themeToggle = document.getElementById("theme-toggle");
const scheduleEl = document.getElementById("schedule");
const errorEl = document.getElementById("error");
const loadingEl = document.getElementById("loading");
const RUSSIAN_DAYS = ["воскресенье", "понедельник", "вторник", "среда", "четверг", "пятница", "суббота"];
const DAY_ORDER = ["понедельник", "вторник", "среда", "четверг", "пятница", "суббота", "воскресенье"];
const MODES = {
groups: { label: "Группа", listUrl: "/api/groups", valueKey: "schedule-group" },
teachers: { label: "Преподаватель", listUrl: "/api/teachers", valueKey: "schedule-teacher" },
rooms: { label: "Аудитория", listUrl: "/api/rooms", valueKey: "schedule-room" },
};
const PARITY_LABEL = { ч: "Числитель", з: "Знаменатель" };
const PARITY_CLASS = { ч: "badge badge-ch", з: "badge badge-zn" };
let currentParity = null;
let mode = "groups";
let requestSeq = 0;
const FETCH_TIMEOUT_MS = 30000;
function todayName() {
return RUSSIAN_DAYS[new Date().getDay()];
}
function enc(value) {
return encodeURIComponent(value);
}
async function fetchJSON(url) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
let resp;
try {
resp = await fetch(url, { signal: controller.signal });
} catch (err) {
throw new Error(err.name === "AbortError" ? "Время запроса истекло" : err.message);
} finally {
clearTimeout(timer);
}
if (!resp.ok) {
let detail = resp.statusText;
try {
const body = await resp.json();
if (body.detail) detail = body.detail;
} catch (_) {}
throw new Error(detail);
}
return resp.json();
}
async function loadWeek() {
try {
const data = await fetchJSON("/api/week");
if (data) currentParity = data.parity;
} catch (_) {}
}
function formatDT(iso) {
const d = new Date(iso);
if (isNaN(d.getTime())) return "";
const pad = (n) => String(n).padStart(2, "0");
return `${pad(d.getDate())}.${pad(d.getMonth() + 1)}.${d.getFullYear()} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
async function loadStatus() {
try {
return await fetchJSON("/api/status");
} catch (_) {
return null;
}
}
/* ---------- навигация по вкладкам ---------- */
function setActiveMenu(view) {
document.querySelectorAll(".side-menu a").forEach((a) => {
a.classList.toggle("active", a.dataset.view === view);
});
}
function fillOptions(list) {
groupSelect.innerHTML = "";
list.forEach((item) => {
const opt = document.createElement("option");
opt.value = item;
opt.textContent = item;
groupSelect.appendChild(opt);
});
}
async function setMode(next) {
mode = next;
localStorage.setItem("schedule-mode", next);
entityLabel.textContent = MODES[next].label;
setActiveMenu(next);
if (next === "rooms") {
entityLabel.textContent = "День";
groupSelect.classList.remove("hidden");
fillOptions(FREE_WEEKDAYS);
const savedDay = localStorage.getItem("schedule-room-day");
if (savedDay && FREE_WEEKDAYS.includes(savedDay)) groupSelect.value = savedDay;
await renderRoomsView(groupSelect.value);
return;
}
groupSelect.classList.remove("hidden");
const list = await fetchJSON(MODES[next].listUrl);
fillOptions(list);
if (!list.length) return;
const saved = localStorage.getItem(MODES[next].valueKey);
if (saved && list.includes(saved)) groupSelect.value = saved;
await renderView();
}
async function renderView() {
const value = groupSelect.value;
if (!value) return;
localStorage.setItem(MODES[mode].valueKey, value);
const seq = ++requestSeq;
scheduleEl.innerHTML = "";
errorEl.classList.add("hidden");
loadingEl.classList.remove("hidden");
try {
let data, status;
if (mode === "groups") {
[data, status] = await Promise.all([
fetchJSON(`/api/schedule/${enc(value)}`),
loadStatus(),
]);
if (seq !== requestSeq) return;
renderGroupSchedule(data, status);
} else {
[data, status] = await Promise.all([
fetchJSON(`/api/teacher/${enc(value)}`),
loadStatus(),
]);
if (seq !== requestSeq) return;
renderTeacher(data, status);
}
} catch (err) {
if (seq !== requestSeq) return;
errorEl.textContent = "Ошибка: " + err.message;
errorEl.classList.remove("hidden");
} finally {
if (seq === requestSeq) loadingEl.classList.add("hidden");
}
}
/* ---------- общие элементы ---------- */
function statusChip(label, value) {
const chip = document.createElement("div");
chip.className = "status-chip";
const lbl = document.createElement("span");
lbl.className = "status-label";
lbl.textContent = label;
const val = document.createElement("span");
val.className = "status-value";
val.textContent = value;
chip.appendChild(lbl);
chip.appendChild(val);
return chip;
}
function statusRowHTML(status) {
const row = document.createElement("div");
row.className = "schedule-status";
if (status) {
const modified = formatDT(status.table_modified);
const synced = formatDT(status.last_updated);
if (modified) row.appendChild(statusChip("Изменение таблицы", modified));
if (synced) row.appendChild(statusChip("Синхронизация", synced));
if (status.error) {
const chip = statusChip("Ошибка", status.error);
chip.classList.add("error");
row.appendChild(chip);
}
}
if (currentParity) {
const chip = document.createElement("div");
chip.className = "status-chip current-week";
const lbl = document.createElement("span");
lbl.className = "status-label";
lbl.textContent = "Сейчас";
const badge = document.createElement("span");
badge.className = "badge active " + (currentParity === "ч" ? "badge-ch" : "badge-zn");
badge.textContent = PARITY_LABEL[currentParity];
chip.appendChild(lbl);
chip.appendChild(badge);
row.appendChild(chip);
}
return row.childNodes.length ? row : null;
}
function makeDayBlock(day) {
const block = document.createElement("div");
block.className = "day-block" + (day === todayName() ? " today" : "");
const head = document.createElement("div");
head.className = "day-head";
head.innerHTML = `<span class="day-title">${day}</span>${day === todayName() ? '<span class="today-badge">сегодня</span>' : ""}`;
block.appendChild(head);
return block;
}
function parityColHTML(label, bodyHtml) {
return `<div class="parity-col">${bodyHtml}</div>`;
}
function alwaysColHTML(lessonsHtml) {
return `<div class="parity-col">${lessonsHtml}</div>`;
}
const OKNO_HTML = '<div class="lesson window">Окно</div>';
const FREE_HTML = '<div class="lesson window">Свободна</div>';
function addPeriodRow(block, num, time, lessonsHTML) {
const row = document.createElement("div");
row.className = "period";
row.innerHTML = `<div><div class="period-num">Пара ${num}</div><div class="period-time">${escapeHTML(time || "")}</div></div>`;
const col = document.createElement("div");
col.innerHTML = lessonsHTML;
row.appendChild(col);
block.appendChild(row);
}
function entityLessonHTML(parity, entry) {
const badge = parity
? `<span class="${PARITY_CLASS[parity]}${parity === currentParity ? " active" : ""}">${PARITY_LABEL[parity]}</span>`
: "";
const details = [];
if (entry.room) details.push(entry.room);
if (entry.teacher) details.push(entry.teacher);
if (entry.groups && entry.groups.length) details.push(entry.groups.join(", "));
return `
<div class="lesson">
<div>
${badge}
${entry.marker ? `<span class="badge badge-kv">${escapeHTML(entry.marker)}</span>` : ""}
<span class="subject">${withBreaks(entry.subject)}</span>
</div>
${details.length ? `<div class="details">${withBreaks(details.join(" · "))}</div>` : ""}
${entry.note ? `<div class="lesson-note">${withBreaks(entry.note)}</div>` : ""}
</div>`;
}
/** Занятия числителя и знаменателя совпадают: неделя не важна. */
function sameEntries(chEntries, znEntries) {
return chEntries.length > 0 && JSON.stringify(chEntries) === JSON.stringify(znEntries);
}
/** Колонки занятий для страниц преподавателя и аудитории. */
function lessonsColHTML(chEntries, znEntries) {
if (sameEntries(chEntries, znEntries)) {
// не зависит от недели — одним списком, без бейджа недели
return `<div class="parities">${alwaysColHTML(chEntries.map((e) => entityLessonHTML(null, e)).join(""))}</div>`;
}
const chHtml = chEntries.map((e) => entityLessonHTML("ч", e)).join("");
const znHtml = znEntries.map((e) => entityLessonHTML("з", e)).join("");
return `<div class="parities">${parityColHTML("", chHtml || OKNO_HTML)}${parityColHTML("", znHtml || OKNO_HTML)}</div>`;
}
function singleLessonHTML(badgeKey, lesson, full) {
const config = {
always: { label: "", cls: "badge badge-always" },
ч: { label: "Числитель", cls: "badge badge-ch" },
з: { label: "Знаменатель", cls: "badge badge-zn" },
};
const { label, cls } = config[badgeKey];
const active = badgeKey !== "always" && badgeKey === currentParity ? " active" : "";
const badge = label ? `<span class="${cls}${active}">${label}</span>` : "";
const details = [lesson.teacher, lesson.room].filter(Boolean).join(" · ");
return `
<div class="lesson${full ? " full" : ""}">
<div>
${badge}
${lesson.marker ? `<span class="badge badge-kv">${escapeHTML(lesson.marker)}</span>` : ""}
<span class="subject">${withBreaks(lesson.subject)}</span>
</div>
${details ? `<div class="details">${withBreaks(details)}</div>` : ""}
${lesson.note ? `<div class="lesson-note">${withBreaks(lesson.note)}</div>` : ""}
</div>`;
}
/* ---------- рендер расписания группы ---------- */
function renderGroupSchedule(data, status) {
scheduleEl.appendChild(Object.assign(document.createElement("div"), {
className: "schedule-title",
textContent: `Расписание группы ${data.group}`,
}));
if (data.semester_until) {
scheduleEl.appendChild(Object.assign(document.createElement("div"), {
className: "schedule-meta",
textContent: `Семестр до ${data.semester_until}`,
}));
}
const top = statusRowHTML(status);
if (top) scheduleEl.appendChild(top);
const daysContainer = document.createElement("div");
scheduleEl.appendChild(daysContainer);
let hasAnyLessons = false;
DAY_ORDER.forEach((day) => {
const periods = data.days[day];
if (!periods) return;
const block = makeDayBlock(day);
const rows = Object.entries(periods)
.sort(([a], [b]) => Number(a) - Number(b))
.map(([num, p]) => {
const always = p.ч && p.з && p.ч.raw === p.з.raw;
return { num, p, always, hasLesson: Boolean(always || p.ч || p.з) };
});
const lastWithLesson = rows.reduce((acc, r, i) => (r.hasLesson ? i : acc), -1);
if (lastWithLesson === -1) {
block.appendChild(Object.assign(document.createElement("div"), {
className: "lesson empty",
textContent: "Занятий нет",
}));
daysContainer.appendChild(block);
return;
}
rows.slice(0, lastWithLesson + 1).forEach(({ num, p, always }) => {
let inner;
if (always) {
inner = `<div class="parities">${alwaysColHTML(p.ч.lessons.map((l) => singleLessonHTML("always", l)).join(""))}</div>`;
hasAnyLessons = true;
} else {
const chHtml = p.ч ? p.ч.lessons.map((l) => singleLessonHTML("ч", l)).join("") : null;
const znHtml = p.з ? p.з.lessons.map((l) => singleLessonHTML("з", l)).join("") : null;
if (!chHtml && !znHtml) {
inner = `<div class="parities">${parityColHTML("Числитель", OKNO_HTML)}</div>`;
} else {
inner = `<div class="parities">${parityColHTML("Числитель", chHtml || OKNO_HTML)}${parityColHTML("Знаменатель", znHtml || OKNO_HTML)}</div>`;
hasAnyLessons = true;
}
}
addPeriodRow(block, num, p.time, inner);
});
daysContainer.appendChild(block);
});
if (!hasAnyLessons) {
daysContainer.innerHTML = "";
daysContainer.appendChild(Object.assign(document.createElement("div"), {
className: "empty-day",
textContent: "Расписание пустое",
}));
}
}
const TEACHER_DAYS = DAY_ORDER.slice(0, 6);
/* ---------- рендер преподавателя ---------- */
function renderTeacher(data, status) {
scheduleEl.appendChild(Object.assign(document.createElement("div"), {
className: "schedule-title",
textContent: `Преподаватель: ${data.full_name || data.name}`,
}));
const top = statusRowHTML(status);
if (top) scheduleEl.appendChild(top);
const daysContainer = document.createElement("div");
scheduleEl.appendChild(daysContainer);
let hasAny = false;
TEACHER_DAYS.forEach((day) => {
const periods = data.days[day];
const occupied = periods && Object.values(periods).some((s) => (s.ч || []).length || (s.з || []).length);
const block = makeDayBlock(day);
if (!occupied) {
block.appendChild(Object.assign(document.createElement("div"), {
className: "lesson empty",
textContent: "Занятий нет",
}));
daysContainer.appendChild(block);
return;
}
Object.entries(periods)
.sort(([a], [b]) => Number(a) - Number(b))
.forEach(([num, slot]) => {
const chEntries = slot.ч || [];
const znEntries = slot.з || [];
if (!chEntries.length && !znEntries.length) return;
hasAny = true;
addPeriodRow(block, num, slot.time, lessonsColHTML(chEntries, znEntries));
});
daysContainer.appendChild(block);
});
if (!hasAny) {
daysContainer.innerHTML = "";
daysContainer.appendChild(Object.assign(document.createElement("div"), {
className: "empty-day",
textContent: "Занятий нет",
}));
}
}
/* ---------- аудитории: занятость по дню и свободные на паре ---------- */
const FREE_WEEKDAYS = ["понедельник", "вторник", "среда", "четверг", "пятница", "суббота"];
const ROOMS_FILTER_KEY = "rooms-filter";
// порядок показа оснащения; сами теги берём из данных таблицы, чтобы новые
// пункты появлялись автоматически
const EQUIPMENT_ORDER = ["ПК", "Проектор", "Интерактивная панель", "Телевизор", "Маркер", "Меловая доска"];
const ROOM_MODES = { busy: "Занятость за день", free: "Свободные на паре" };
const ROOM_PERIODS = [1, 2, 3, 4, 5, 6, 7, 8];
let roomsState = { day: null, mode: "busy", period: 1, loaded: null, status: null, filter: loadRoomsFilter() };
/** Фильтры аудиторий живут между заходами. */
function loadRoomsFilter() {
try {
const raw = JSON.parse(localStorage.getItem(ROOMS_FILTER_KEY) || "{}");
return {
type: typeof raw.type === "string" ? raw.type : "",
capacity: raw.capacity ? String(raw.capacity) : "",
equipment: Array.isArray(raw.equipment) ? raw.equipment.filter((t) => typeof t === "string") : [],
mode: raw.mode === "or" ? "or" : "and",
};
} catch (_) {
return { type: "", capacity: "", equipment: [], mode: "and" };
}
}
function saveRoomsFilter() {
localStorage.setItem(ROOMS_FILTER_KEY, JSON.stringify(roomsState.filter));
}
/** Русское склонение существительного при числе. */
function pluralRu(count, one, few, many) {
const mod10 = count % 10;
const mod100 = count % 100;
if (mod10 === 1 && mod100 !== 11) return one;
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20)) return few;
return many;
}
function roomsAll() {
return (roomsState.loaded && roomsState.loaded.rooms) || [];
}
/** Теги оснащения: сначала известные по порядку, затем всё остальное из данных. */
function equipmentTags() {
const found = new Set();
roomsAll().forEach((room) => (room.equipment || []).forEach((tag) => found.add(tag)));
const known = EQUIPMENT_ORDER.filter((tag) => found.has(tag));
const extra = [...found].filter((tag) => !EQUIPMENT_ORDER.includes(tag)).sort((a, b) => a.localeCompare(b, "ru"));
return [...known, ...extra];
}
/** Разобранные атрибуты аудитории: «комп.класс · 24 места · ПК, Проектор». */
function roomAttrsText(room) {
const parts = [];
if (room.type) parts.push(room.type);
if (room.capacity != null) parts.push(`${room.capacity} мест`);
if ((room.equipment || []).length) parts.push(room.equipment.join(", "));
return parts.join(" · ");
}
function roomMatchesFilter(room) {
const filter = roomsState.filter;
const group = room.type_group || "";
if (filter.type === "__none__") {
if (group) return false; // только аудитории без указанного типа
} else if (filter.type && group !== filter.type) {
return false;
}
const min = parseInt(filter.capacity, 10);
if (!isNaN(min) && min > 0 && (room.capacity == null || room.capacity < min)) return false;
const wanted = filter.equipment;
if (wanted.length) {
const has = new Set(room.equipment || []);
const ok = filter.mode === "or" ? wanted.some((t) => has.has(t)) : wanted.every((t) => has.has(t));
if (!ok) return false;
}
return true;
}
function roomFreeAt(room, period) {
const slot = (room.periods || {})[String(period)] || {};
return !((slot.ч || []).length || (slot.з || []).length);
}
function roomAvailableOn(room) {
const days = room.available_days;
return !days || days.includes(roomsState.day);
}
async function renderRoomsView(day) {
roomsState.day = FREE_WEEKDAYS.includes(day)
? day
: (FREE_WEEKDAYS.includes(todayName()) ? todayName() : "понедельник");
const seq = ++requestSeq;
scheduleEl.innerHTML = "";
errorEl.classList.add("hidden");
loadingEl.classList.remove("hidden");
try {
const status = await loadStatus();
const data = await fetchJSON(`/api/rooms-day?day=${enc(roomsState.day)}`);
if (seq !== requestSeq) return;
roomsState.loaded = data;
roomsState.status = status;
drawRoomsScreen(status);
} catch (err) {
if (seq !== requestSeq) return;
errorEl.textContent = "Ошибка: " + err.message;
errorEl.classList.remove("hidden");
} finally {
if (seq === requestSeq) loadingEl.classList.add("hidden");
}
}
function drawRoomsScreen(status) {
scheduleEl.innerHTML = "";
scheduleEl.appendChild(Object.assign(document.createElement("div"), {
className: "schedule-title",
textContent: "Аудитории",
}));
const top = statusRowHTML(status);
if (top) scheduleEl.appendChild(top);
scheduleEl.appendChild(roomsToolbar());
const wrap = document.createElement("div");
wrap.className = "rooms-wrap";
wrap.appendChild(roomsListBlock());
scheduleEl.appendChild(wrap);
updateRoomsSummary(); // панель уже в DOM — можно заполнить счётчик
}
function roomsToolbar() {
const all = roomsAll();
// близкие формулировки типов объединены в общий тип (room_type_group)
const types = [...new Set(all.map((r) => r.type_group).filter(Boolean))].sort((a, b) => a.localeCompare(b, "ru"));
const filter = roomsState.filter;
const box = document.createElement("div");
box.className = "rooms-filters";
box.innerHTML = `
<div class="rf-row rf-tabs">
${Object.entries(ROOM_MODES).map(([key, label]) =>
`<button type="button" class="rf-tab${roomsState.mode === key ? " active" : ""}" data-mode="${key}">${label}</button>`
).join("")}
${roomsState.mode === "free" ? `
<label class="rf-field rf-narrow"><span>Пара</span>
<select id="rf-period">${ROOM_PERIODS.map((p) =>
`<option value="${p}"${roomsState.period === p ? " selected" : ""}>Пара ${p}</option>`).join("")}</select>
</label>` : ""}
</div>
<div class="rf-row">
<label class="rf-field"><span>Тип аудитории</span>
<select id="rf-type">
<option value="">все типы</option>
${types.map((t) => `<option value="${escapeHTML(t)}"${filter.type === t ? " selected" : ""}>${escapeHTML(t)}</option>`).join("")}
<option value="__none__"${filter.type === "__none__" ? " selected" : ""}>без типа</option>
</select>
</label>
<label class="rf-field rf-narrow"><span>Мест не меньше</span>
<input id="rf-capacity" type="number" min="1" step="1" placeholder="например 30" value="${escapeHTML(filter.capacity)}">
</label>
<label class="rf-field rf-narrow"><span>Оснащение</span>
<select id="rf-mode">
<option value="and"${filter.mode === "and" ? " selected" : ""}>все выбранные (И)</option>
<option value="or"${filter.mode === "or" ? " selected" : ""}>любое из выбранных (ИЛИ)</option>
</select>
</label>
</div>
<div class="rf-equip">
${equipmentTags().map((tag) => `
<label class="rf-check"><input type="checkbox" data-equip="${escapeHTML(tag)}"${filter.equipment.includes(tag) ? " checked" : ""}> ${escapeHTML(tag)}</label>
`).join("")}
</div>
<div class="rf-row rf-actions">
<button type="button" class="rf-reset" id="rf-reset">Сбросить фильтры</button>
<div class="rf-summary" id="rf-summary"></div>
</div>
<div class="rf-hint hidden" id="rf-hint"></div>
`;
box.querySelectorAll(".rf-tab").forEach((btn) => {
btn.addEventListener("click", () => {
roomsState.mode = btn.dataset.mode;
drawRoomsScreen(roomsState.status);
});
});
const periodSel = box.querySelector("#rf-period");
if (periodSel) {
periodSel.addEventListener("change", () => {
roomsState.period = Number(periodSel.value);
refreshRoomsList();
});
}
box.querySelector("#rf-type").addEventListener("change", (e) => {
roomsState.filter.type = e.target.value;
saveRoomsFilter();
refreshRoomsList();
});
box.querySelector("#rf-capacity").addEventListener("input", (e) => {
roomsState.filter.capacity = e.target.value;
saveRoomsFilter();
refreshRoomsList();
});
box.querySelector("#rf-mode").addEventListener("change", (e) => {
roomsState.filter.mode = e.target.value;
saveRoomsFilter();
refreshRoomsList();
});
box.querySelectorAll("[data-equip]").forEach((input) => {
input.addEventListener("change", () => {
const tag = input.dataset.equip;
const set = new Set(roomsState.filter.equipment);
if (input.checked) set.add(tag); else set.delete(tag);
roomsState.filter.equipment = [...set];
saveRoomsFilter();
refreshRoomsList();
});
});
box.querySelector("#rf-reset").addEventListener("click", () => {
roomsState.filter = { type: "", capacity: "", equipment: [], mode: "and" };
saveRoomsFilter();
drawRoomsScreen(roomsState.status);
});
return box;
}
/** Перерисовать только список аудиторий (фильтры и поля ввода не трогаем). */
function refreshRoomsList() {
const old = scheduleEl.querySelector(".rooms-list, .rooms-empty");
const next = roomsListBlock();
if (old) old.replaceWith(next); else (scheduleEl.querySelector(".rooms-wrap") || scheduleEl).appendChild(next);
updateRoomsSummary();
}
function updateRoomsSummary() {
const summary = scheduleEl.querySelector("#rf-summary");
const hint = scheduleEl.querySelector("#rf-hint");
if (!summary) return;
const all = roomsAll();
const shown = visibleRooms();
const pieces = [`Показано ${shown.length} из ${all.length} аудиторий`];
if (roomsState.mode === "free") pieces.push(`свободны на паре ${roomsState.period}`);
summary.textContent = pieces.join(" · ");
const notes = [];
const min = parseInt(roomsState.filter.capacity, 10);
if (!isNaN(min) && min > 0) {
const unknown = all.filter((r) => r.capacity == null).length;
if (unknown) {
notes.push(`скрыто ${unknown} ${pluralRu(unknown, "аудитория", "аудитории", "аудиторий")}: в таблице нет данных о вместимости`);
}
}
if (roomsState.mode !== "free") {
const restricted = all.filter((r) => !roomAvailableOn(r)).length;
if (restricted) {
notes.push(`${pluralRu(restricted, "аудитория отмечена", "аудитории отмечены", "аудиторий отмечено")} как доступные только по отдельным дням`);
}
}
if (hint) {
hint.textContent = notes.join(". ");
hint.classList.toggle("hidden", !notes.length);
}
}
function visibleRooms() {
const filterFn = (room) => roomMatchesFilter(room);
if (roomsState.mode !== "free") return roomsAll().filter(filterFn);
return roomsAll().filter((room) => filterFn(room) && roomAvailableOn(room) && roomFreeAt(room, roomsState.period));
}
function roomsListBlock() {
const rooms = visibleRooms();
const list = document.createElement("div");
list.className = "rooms-list";
if (!rooms.length) {
list.className = "rooms-empty";
list.textContent = "Под фильтры ничего не подошло — ослабьте условия или сбросьте фильтры.";
return list;
}
const free = roomsState.mode === "free";
if (!free) {
const periodNums = [];
roomsAll().forEach((r) => Object.keys(r.periods || {}).forEach((p) => {
if (!periodNums.includes(p)) periodNums.push(p);
}));
periodNums.sort((a, b) => Number(a) - Number(b));
rooms.forEach((room) => list.appendChild(roomsDayCard(room, periodNums)));
return list;
}
rooms.forEach((room) => list.appendChild(roomsFreeCard(room)));
return list;
}
function roomsCardHead(room) {
const head = document.createElement("div");
head.className = "day-head";
const attrs = roomAttrsText(room);
const restricted = room.available_days && !roomAvailableOn(room);
head.innerHTML = `
<div class="room-title">
<span class="day-title">${escapeHTML(room.room)}</span>
${attrs ? `<div class="room-attrs">${escapeHTML(attrs)}</div>` : ""}
${room.info ? `<div class="room-info">${escapeHTML(room.info)}</div>` : ""}
${room.available_days ? `<div class="room-note">в таблице отмечено: только ${escapeHTML(room.available_days.join(", "))}</div>` : ""}
${restricted ? `<div class="room-note bad">по таблице в этот день занятий в ней нет</div>` : ""}
</div>`;
return head;
}
function roomsDayCard(room, periodNums) {
const card = document.createElement("div");
card.className = "room-card";
card.appendChild(roomsCardHead(room));
const periods = room.periods || {};
periodNums.forEach((p) => {
const slot = periods[p] || {};
const row = document.createElement("div");
row.className = "room-row";
const left = document.createElement("div");
left.className = "room-row-num";
left.innerHTML = `Пара ${p}${slot.time ? `<div class="period-time">${escapeHTML(slot.time)}</div>` : ""}`;
row.appendChild(left);
const body = document.createElement("div");
body.className = "room-row-body";
const chEntries = slot.ч || [];
const znEntries = slot.з || [];
if (!chEntries.length && !znEntries.length) {
body.innerHTML = `<div class="parities">${parityColHTML("", OKNO_HTML)}</div>`;
} else {
body.innerHTML = lessonsColHTML(chEntries, znEntries);
}
row.appendChild(body);
card.appendChild(row);
});
return card;
}
function roomsFreeCard(room) {
const card = document.createElement("div");
card.className = "room-card room-card-free";
card.appendChild(roomsCardHead(room));
const note = document.createElement("div");
note.className = "room-free-note";
note.textContent = `Свободна на паре ${roomsState.period} (${roomsState.day})`;
card.appendChild(note);
return card;
}
function escapeHTML(s) {
return s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
}
/**
* Экранирует текст и расставляет точки переноса <wbr>.
* Длинные ссылки (и вообще слова без пробелов) переносятся в любом браузере —
* не полагаемся на поддержку overflow-wrap: anywhere старыми движками.
*/
function withBreaks(text) {
const WBR = "\u0001"; // служебный маркер, в тексте таблицы не встречается
if (!text) return "";
const marked = String(text)
.replace(/([/?&=._\-])(?=\S)/g, "$1" + WBR) // после служебных символов ссылок
.replace(/(\S{24})(?=\S)/g, "$1" + WBR); // и не реже, чем каждые 24 символа
return escapeHTML(marked).split(WBR).join("<wbr>");
}
function applyTheme(theme) {
document.documentElement.setAttribute("data-theme", theme);
localStorage.setItem("theme", theme);
}
function initTheme() {
const saved = localStorage.getItem("theme");
applyTheme(saved === "dark" ? "dark" : "light");
}
themeToggle.addEventListener("click", () => {
const current = document.documentElement.getAttribute("data-theme") === "dark" ? "dark" : "light";
applyTheme(current === "dark" ? "light" : "dark");
});
initTheme();
document.querySelectorAll(".side-menu a").forEach((link) => {
link.addEventListener("click", (e) => {
e.preventDefault();
setMode(link.dataset.view).catch((err) => {
errorEl.textContent = "Ошибка: " + err.message;
errorEl.classList.remove("hidden");
});
});
});
groupSelect.addEventListener("change", () => {
if (mode === "rooms") {
localStorage.setItem("schedule-room-day", groupSelect.value);
renderRoomsView(groupSelect.value).catch((err) => {
errorEl.textContent = "Ошибка: " + err.message;
errorEl.classList.remove("hidden");
});
return;
}
renderView();
});
loadWeek().finally(() => {
const savedMode = localStorage.getItem("schedule-mode");
const initial = ["groups", "teachers", "rooms"].includes(savedMode) ? savedMode : "groups";
setMode(initial).catch((err) => {
errorEl.textContent = "Ошибка: " + err.message;
errorEl.classList.remove("hidden");
});
});
// PWA: ярлык «на главный экран» с иконкой приложения (манифест + service worker)
if ("serviceWorker" in navigator) {
window.addEventListener("load", () => {
navigator.serviceWorker.register("/sw.js").catch(() => {});
});
}