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]) => `` ).join("")} ${roomsState.mode === "free" ? ` ` : ""}
${equipmentTags().map((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(() => {}); }); }