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 = `${day} ${day === todayName() ? 'сегодня ' : ""}`;
block.appendChild(head);
return block;
}
function parityColHTML(label, bodyHtml) {
return `
${bodyHtml}
`;
}
function alwaysColHTML(lessonsHtml) {
return `${lessonsHtml}
`;
}
const OKNO_HTML = 'Окно
';
const FREE_HTML = 'Свободна
';
function addPeriodRow(block, num, time, lessonsHTML) {
const row = document.createElement("div");
row.className = "period";
row.innerHTML = `Пара ${num}
${escapeHTML(time || "")}
`;
const col = document.createElement("div");
col.innerHTML = lessonsHTML;
row.appendChild(col);
block.appendChild(row);
}
function entityLessonHTML(parity, entry) {
const badge = parity
? `${PARITY_LABEL[parity]} `
: "";
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 `
${badge}
${entry.marker ? `${escapeHTML(entry.marker)} ` : ""}
${withBreaks(entry.subject)}
${details.length ? `
${withBreaks(details.join(" · "))}
` : ""}
${entry.note ? `
${withBreaks(entry.note)}
` : ""}
`;
}
/** Занятия числителя и знаменателя совпадают: неделя не важна. */
function sameEntries(chEntries, znEntries) {
return chEntries.length > 0 && JSON.stringify(chEntries) === JSON.stringify(znEntries);
}
/** Колонки занятий для страниц преподавателя и аудитории. */
function lessonsColHTML(chEntries, znEntries) {
if (sameEntries(chEntries, znEntries)) {
// не зависит от недели — одним списком, без бейджа недели
return `${alwaysColHTML(chEntries.map((e) => entityLessonHTML(null, e)).join(""))}
`;
}
const chHtml = chEntries.map((e) => entityLessonHTML("ч", e)).join("");
const znHtml = znEntries.map((e) => entityLessonHTML("з", e)).join("");
return `${parityColHTML("", chHtml || OKNO_HTML)}${parityColHTML("", znHtml || OKNO_HTML)}
`;
}
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 ? `${label} ` : "";
const details = [lesson.teacher, lesson.room].filter(Boolean).join(" · ");
return `
${badge}
${lesson.marker ? `${escapeHTML(lesson.marker)} ` : ""}
${withBreaks(lesson.subject)}
${details ? `
${withBreaks(details)}
` : ""}
${lesson.note ? `
${withBreaks(lesson.note)}
` : ""}
`;
}
/* ---------- рендер расписания группы ---------- */
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 = `${alwaysColHTML(p.ч.lessons.map((l) => singleLessonHTML("always", l)).join(""))}
`;
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 = `${parityColHTML("Числитель", OKNO_HTML)}
`;
} else {
inner = `${parityColHTML("Числитель", chHtml || OKNO_HTML)}${parityColHTML("Знаменатель", znHtml || OKNO_HTML)}
`;
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 = `
${Object.entries(ROOM_MODES).map(([key, label]) =>
`${label} `
).join("")}
${roomsState.mode === "free" ? `
Пара
${ROOM_PERIODS.map((p) =>
`Пара ${p} `).join("")}
` : ""}
Тип аудитории
все типы
${types.map((t) => `${escapeHTML(t)} `).join("")}
без типа
Мест не меньше
Оснащение
все выбранные (И)
любое из выбранных (ИЛИ)
${equipmentTags().map((tag) => `
${escapeHTML(tag)}
`).join("")}
`;
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 = `
${escapeHTML(room.room)}
${attrs ? `
${escapeHTML(attrs)}
` : ""}
${room.info ? `
${escapeHTML(room.info)}
` : ""}
${room.available_days ? `
в таблице отмечено: только ${escapeHTML(room.available_days.join(", "))}
` : ""}
${restricted ? `
по таблице в этот день занятий в ней нет
` : ""}
`;
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 ? `${escapeHTML(slot.time)}
` : ""}`;
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 = `${parityColHTML("", OKNO_HTML)}
`;
} 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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
}
/**
* Экранирует текст и расставляет точки переноса .
* Длинные ссылки (и вообще слова без пробелов) переносятся в любом браузере —
* не полагаемся на поддержку 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("");
}
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(() => {});
});
}