From dcd9b854a03c1efd5ba104ec9fb806cafd9fd427 Mon Sep 17 00:00:00 2001 From: Vladislav Kopnin Date: Tue, 8 Sep 2026 11:14:33 +0300 Subject: [PATCH] =?UTF-8?q?=D0=91=D0=B0=D0=B7=D0=BE=D0=B2=D0=B0=D1=8F=20?= =?UTF-8?q?=D1=84=D1=83=D0=BD=D0=BA=D1=86=D0=B8=D0=BE=D0=BD=D0=B0=D0=BB?= =?UTF-8?q?=D1=8C=D0=BD=D0=BE=D1=81=D1=82=D1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 11 + .env.example | 5 + .gitignore | 4 + Dockerfile | 25 + Makefile | 29 + deploy.sh | 23 + docker-compose.yml | 36 ++ requirements-dev.txt | 2 + requirements.txt | 4 + src/backend/main.py | 298 ++++++++++ src/backend/schedule_parser.py | 799 ++++++++++++++++++++++++++ src/frontend/main.py | 93 +++ src/frontend/static/app.js | 526 +++++++++++++++++ src/frontend/static/img/favicon.png | Bin 0 -> 315 bytes src/frontend/static/img/owl-white.svg | 198 +++++++ src/frontend/static/img/owl.svg | 61 ++ src/frontend/static/index.html | 58 ++ src/frontend/static/style.css | 683 ++++++++++++++++++++++ 18 files changed, 2855 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 Makefile create mode 100755 deploy.sh create mode 100644 docker-compose.yml create mode 100644 requirements-dev.txt create mode 100644 requirements.txt create mode 100644 src/backend/main.py create mode 100644 src/backend/schedule_parser.py create mode 100644 src/frontend/main.py create mode 100644 src/frontend/static/app.js create mode 100644 src/frontend/static/img/favicon.png create mode 100644 src/frontend/static/img/owl-white.svg create mode 100644 src/frontend/static/img/owl.svg create mode 100644 src/frontend/static/index.html create mode 100644 src/frontend/static/style.css diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..6ce298c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +.git +.gitignore +.idea +.venv +**/__pycache__ +**/*.pyc +*.log +.env +.env.local +deploy.sh +Makefile diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..cff39dd --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +schedule_link= +SCHEDULE_CACHE_TTL=900 +API_PORT=8000 +FRONTEND_PORT=8080 +SCHEDULE_API=http://api:8000 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..011ea13 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +**/*.idea +**/*.env +/.idea/ +.env \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2ac1028 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,25 @@ +FROM python:3.12-slim AS base + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app + +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt \ + && groupadd --system app && useradd --system --gid app app + +FROM base AS api + +COPY --chown=app:app src/backend/main.py src/backend/schedule_parser.py ./ +USER app +EXPOSE 8000 +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"] + +FROM base AS frontend + +COPY --chown=app:app src/frontend/main.py frontend/main.py +COPY --chown=app:app src/frontend/static frontend/static +USER app +EXPOSE 8080 +CMD ["uvicorn", "frontend.main:app", "--host", "0.0.0.0", "--port", "8080"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..1f5f1fb --- /dev/null +++ b/Makefile @@ -0,0 +1,29 @@ +.PHONY: build up down logs restart deploy ps dev + +build: ## Собрать образы микросервисов + docker compose build + +up: ## Запустить все микросервисы одной командой + docker compose up -d + +down: ## Остановить и удалить контейнеры + docker compose down + +restart: ## Перезапустить микросервисы + docker compose restart + +logs: ## Логи всех микросервисов + docker compose logs -f + +ps: ## Статус микросервисов + docker compose ps + +deploy: ## Собрать и запустить (деплой на сервере) + ./deploy.sh + +dev: ## Запустить локально без Docker (бэкенд + фронт) + @echo "Запуск бэкенда на :8000 и фронта на :8080..." + @trap 'kill 0' EXIT; \ + cd src/backend && $(CURDIR)/.venv/bin/python -m uvicorn main:app --reload --port 8000 & \ + cd src/frontend && $(CURDIR)/.venv/bin/python -m uvicorn main:app --port 8080 & \ + wait diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..6b76268 --- /dev/null +++ b/deploy.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname "$0")" + +if [ ! -f .env ]; then + echo "Ошибка: файл .env не найден. Скопируйте .env.example в .env и укажите schedule_link." >&2 + exit 1 +fi + +if ! grep -qE '^schedule_link=.+' .env; then + echo "Ошибка: в .env не заполнена переменная schedule_link." >&2 + exit 1 +fi + +echo "Сборка образов..." +docker compose build + +echo "Запуск микросервисов..." +docker compose up -d --remove-orphans + +echo +docker compose ps diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4fad8b5 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,36 @@ +services: + api: + build: + context: . + target: api + environment: + schedule_link: ${schedule_link} + SCHEDULE_CACHE_TTL: ${SCHEDULE_CACHE_TTL:-900} + ports: + - "${API_PORT:-8000}:8000" + restart: unless-stopped + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/groups', timeout=5)"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + + frontend: + build: + context: . + target: frontend + environment: + SCHEDULE_API: ${SCHEDULE_API:-http://api:8000} + ports: + - "${FRONTEND_PORT:-8080}:8080" + restart: unless-stopped + depends_on: + api: + condition: service_healthy + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/', timeout=5)"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..428ebb2 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,2 @@ +-r requirements.txt +httpx2 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..3fe0617 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +fastapi +uvicorn +python-dotenv +requests diff --git a/src/backend/main.py b/src/backend/main.py new file mode 100644 index 0000000..3fba284 --- /dev/null +++ b/src/backend/main.py @@ -0,0 +1,298 @@ +import os +import time +from contextlib import asynccontextmanager +from datetime import datetime +from pathlib import Path + +import uvicorn +from dotenv import load_dotenv +from fastapi import FastAPI, HTTPException, Query + +from schedule_parser import ( + DAYS, + ScheduleError, + build_day_times, + build_group_schedule, + build_room_schedule, + build_teacher_room_index, + build_teacher_schedule, + current_parity, + download_schedule, + extract_week_ranges, + fetch_table_modified, + parse_rooms_info, + parse_schedule, + parse_teachers_full, + parse_workbook, + sort_rooms, +) + +def _find_dotenv(): + path = Path(__file__).resolve().parent + for _ in range(5): + env = path / ".env" + if env.is_file(): + return env + if path.parent == path: + break + path = path.parent + return None + + +_dotenv_path = _find_dotenv() +if _dotenv_path: + load_dotenv(_dotenv_path) + +SCHEDULE_LINK = os.getenv("schedule_link") or os.getenv("SCHEDULE_LINK", "").strip() +CACHE_TTL = int(os.getenv("SCHEDULE_CACHE_TTL", "900")) + +_cache = { + "data": None, + "weeks": None, + "modified": None, + "loaded_at": 0.0, + "error": None, + "index": None, + "teacher_full": {}, + "room_info": {}, +} + + +@asynccontextmanager +async def lifespan(app: FastAPI): + try: + _load_data() + except Exception: + pass + yield + + +app = FastAPI( + title="Расписание", + description="Расписание групп из Яндекс.Таблицы", + lifespan=lifespan, +) + + +def _load_data(force: bool = False): + if not SCHEDULE_LINK: + raise ScheduleError("Ссылка schedule_link не задана в .env") + now = time.time() + if ( + not force + and _cache["data"] is not None + and now - _cache["loaded_at"] < CACHE_TTL + and _cache["error"] is None + ): + return _cache["data"], _cache["weeks"] + + modified = fetch_table_modified(SCHEDULE_LINK) + + if ( + not force + and modified + and modified == _cache["modified"] + and _cache["data"] is not None + and _cache["error"] is None + ): + _cache["loaded_at"] = now + return _cache["data"], _cache["weeks"] + + xlsx = download_schedule(SCHEDULE_LINK) + sheets = parse_workbook(xlsx) + data = parse_schedule(sheets) + weeks = extract_week_ranges(sheets) + _cache["data"] = data + _cache["weeks"] = weeks + _cache["modified"] = modified + _cache["loaded_at"] = now + _cache["error"] = None + _cache["index"] = None + _cache["teacher_full"] = parse_teachers_full(sheets) + _cache["room_info"] = parse_rooms_info(sheets) + return data, weeks + + +def _get_index(force: bool = False): + data, _ = _get_data(force=force) + if _cache["index"] is None: + _cache["index"] = build_teacher_room_index(data) + return _cache["index"] + + +def _get_data(force: bool = False): + try: + return _load_data(force=force) + except ScheduleError as exc: + _cache["error"] = str(exc) + raise HTTPException(status_code=502, detail=str(exc)) + + +@app.get("/") +def index(): + return { + "title": "Расписание", + "endpoints": { + "/groups": "Список групп", + "/schedule/{group}": "Расписание группы", + "/schedule/{group}/{day}": "Расписание группы на день", + "/today?group={group}": "Расписание группы на сегодня", + }, + } + + +@app.get("/groups") +def groups(force: bool = Query(False)): + data, _ = _get_data(force=force) + return sorted( + data.keys(), + key=lambda g: ( + 0 if g.endswith("БО") else (1 if g.endswith("МО") else 2), + g, + ), + ) + + +@app.get("/teachers") +def teachers(force: bool = Query(False)): + teachers_map, _ = _get_index(force=force) + return sorted(teachers_map.keys()) + + +@app.get("/rooms") +def rooms(force: bool = Query(False)): + _, rooms_map = _get_index(force=force) + return sort_rooms(rooms_map.keys()) + + +@app.get("/teacher/{name}") +def teacher(name: str, force: bool = Query(False)): + teachers_map, _ = _get_index(force=force) + result = build_teacher_schedule(teachers_map, name) + if result is None: + raise HTTPException(status_code=404, detail=f"Преподаватель «{name}» не найден") + result["full_name"] = _cache.get("teacher_full", {}).get(name, name) + return result + + +@app.get("/room/{label}") +def room(label: str, force: bool = Query(False)): + data, _ = _get_data(force=force) + _, rooms_map = _get_index(force=force) + result = build_room_schedule(rooms_map, label, build_day_times(data)) + if result is None: + raise HTTPException(status_code=404, detail=f"Аудитория «{label}» не найдена") + result["info"] = _cache.get("room_info", {}).get(label, {}).get("info", "") + return result + + +@app.get("/rooms-free") +def rooms_free( + day: str, + period: int, + force: bool = Query(False), +): + normalized = day.strip().lower() + if normalized not in DAYS: + raise HTTPException(status_code=400, detail=f"День должен быть одним из: {', '.join(DAYS)}") + if period < 1 or period > 8: + raise HTTPException(status_code=400, detail="Пара должна быть от 1 до 8") + _, rooms_map = _get_index(force=force) + room_info = _cache.get("room_info", {}) + free = [] + for label in sort_rooms(room_info.keys()): + occupied = rooms_map.get(label, {}).get(normalized, {}).get(str(period), {}) + busy = bool((occupied.get("ч") or []) or (occupied.get("з") or [])) + if not busy: + free.append({"room": label, "info": room_info[label].get("info", "")}) + return free + + +@app.get("/rooms-day") +def rooms_day(day: str, force: bool = Query(False)): + normalized = day.strip().lower() + if normalized not in DAYS: + raise HTTPException(status_code=400, detail=f"День должен быть одним из: {', '.join(DAYS)}") + data, _ = _get_data(force=force) + _, rooms_map = _get_index(force=force) + room_info = _cache.get("room_info", {}) + day_times = build_day_times(data).get(normalized, {}) + + labels = set(rooms_map.keys()) | set(room_info.keys()) + rooms_out = [] + for label in sort_rooms(labels): + info = room_info.get(label, {}).get("info", "") + occupied_day = rooms_map.get(label, {}).get(normalized, {}) + periods = {} + keys = set(occupied_day.keys()) | set(day_times.keys()) + for period in sorted(keys, key=lambda p: int(p)): + slot = occupied_day.get(period, {}) + periods[str(period)] = { + "time": day_times.get(period, slot.get("time", "")), + "ч": slot.get("ч", []) or [], + "з": slot.get("з", []) or [], + } + rooms_out.append({"room": label, "info": info, "periods": periods}) + return {"day": normalized, "rooms": rooms_out} + + +@app.get("/week") +def week(force: bool = Query(False)): + _, weeks = _get_data(force=force) + today = datetime.now().date() + return { + "today": today.isoformat(), + "parity": current_parity(weeks, today), + "weeks": [ + {"parity": w["parity"], "start": w["start"].isoformat(), "end": w["end"].isoformat()} + for w in weeks + ], + } + + +@app.get("/status") +def status(force: bool = Query(False)): + _get_data(force=force) + return { + "cache_ttl_seconds": CACHE_TTL, + "last_updated": ( + datetime.fromtimestamp(_cache["loaded_at"], datetime.now().astimezone().tzinfo).isoformat() if _cache["loaded_at"] else None + ), + "table_modified": _cache["modified"], + } + + +@app.get("/schedule/{group}") +def schedule(group: str, force: bool = Query(False)): + data, _ = _get_data(force=force) + if group not in data: + raise HTTPException(status_code=404, detail=f"Группа «{group}» не найдена") + return build_group_schedule(data, group) + + +@app.get("/schedule/{group}/{day}") +def schedule_day(group: str, day: str, force: bool = Query(False)): + data, _ = _get_data(force=force) + if group not in data: + raise HTTPException(status_code=404, detail=f"Группа «{group}» не найдена") + normalized = day.strip().lower() + if normalized not in DAYS: + raise HTTPException(status_code=400, detail=f"День должен быть одним из: {', '.join(DAYS)}") + sched = build_group_schedule(data, group) + day_data = sched["days"].get(normalized, {}) + return {"group": group, "day": normalized, "periods": day_data} + + +@app.get("/today") +def today(group: str, force: bool = Query(False)): + data, _ = _get_data(force=force) + if group not in data: + raise HTTPException(status_code=404, detail=f"Группа «{group}» не найдена") + weekday = datetime.now().weekday() + day_name = DAYS[weekday] + sched = build_group_schedule(data, group) + return {"group": group, "date": datetime.now().date().isoformat(), "day": day_name, "periods": sched["days"].get(day_name, {})} + + +if __name__ == "__main__": + uvicorn.run("main:app", host="127.0.0.1", port=8000, reload=True) diff --git a/src/backend/schedule_parser.py b/src/backend/schedule_parser.py new file mode 100644 index 0000000..0da8711 --- /dev/null +++ b/src/backend/schedule_parser.py @@ -0,0 +1,799 @@ +import io +import re +import zipfile +import xml.etree.ElementTree as ET +from datetime import date, datetime, timedelta + +import requests + +XLSX_NS = "http://schemas.openxmlformats.org/spreadsheetml/2006/main" +REL_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships" +MAIN_SHEET = "Бакалавры и магистры" + +DAYS = ["понедельник", "вторник", "среда", "четверг", "пятница", "суббота", "воскресенье"] + +_PERIOD_RE = re.compile(r"^\s*(\d+)\s*(.*)$") +_REF_RE = re.compile(r"^([A-Z]+)(\d+)$") + + +class ScheduleError(Exception): + pass + + +def _iter_text(node): + for el in node.iter(f"{{{XLSX_NS}}}t"): + if el.text: + yield el.text + + +def _col_index(col_letter): + idx = 0 + for ch in col_letter: + idx = idx * 26 + (ord(ch) - ord("A") + 1) + return idx - 1 + + +def _parse_ref(ref): + m = _REF_RE.match(ref) + if not m: + raise ValueError(f"Некорректная ссылка на ячейку: {ref}") + return _col_index(m.group(1)), int(m.group(2)) - 1 + + +RED_OPEN = "\u27EA" +RED_CLOSE = "\u27EB" + + +def _is_red_rgb(rgb): + try: + if not rgb or len(rgb) < 8 or not rgb.startswith("FF"): + return False + r = int(rgb[2:4], 16) + g = int(rgb[4:6], 16) + b = int(rgb[6:8], 16) + except (ValueError, TypeError): + return False + return r > 0x90 and g < 0x70 and b < 0x70 + + +def _parse_shared_strings(xml_bytes): + root = ET.fromstring(xml_bytes) + out = [] + for si in root.iter(f"{{{XLSX_NS}}}si"): + parts = [] + for child in si: + if child.tag == f"{{{XLSX_NS}}}t": + parts.append((child.text or "", False)) + continue + if child.tag != f"{{{XLSX_NS}}}r": + continue + t = child.find(f"{{{XLSX_NS}}}t") + text = t.text if t is not None else "" + red = False + rpr = child.find(f"{{{XLSX_NS}}}rPr") + if rpr is not None: + color = rpr.find(f"{{{XLSX_NS}}}color") + if color is not None: + red = _is_red_rgb(color.get("rgb")) + parts.append((text or "", red)) + chunks = [] + for text, red in parts: + if red: + chunks.append(RED_OPEN + text + RED_CLOSE) + else: + chunks.append(text) + out.append("".join(chunks)) + return out + + +def _parse_sheet(xml_bytes, shared): + root = ET.fromstring(xml_bytes) + cells = {} + max_row = 0 + max_col = 0 + hidden_cols = set() + + cols_node = root.find(f".//{{{XLSX_NS}}}cols") + if cols_node is not None: + for col in cols_node.iter(f"{{{XLSX_NS}}}col"): + if col.get("hidden") == "1": + try: + c_min = int(col.get("min")) + c_max = int(col.get("max")) + except (TypeError, ValueError): + continue + hidden_cols.update(range(c_min - 1, c_max)) + + for row in root.iter(f"{{{XLSX_NS}}}row"): + r = int(row.get("r")) - 1 + for cell in row.iter(f"{{{XLSX_NS}}}c"): + ref = cell.get("r") + if not ref: + continue + c = _col_index("".join(ch for ch in ref if ch.isalpha())) + t = cell.get("t") + v = cell.find(f"{{{XLSX_NS}}}v") + if t == "s" and v is not None and v.text: + idx = int(v.text) + value = shared[idx] if idx < len(shared) else "" + elif v is not None and v.text is not None: + value = v.text + else: + is_node = cell.find(f"{{{XLSX_NS}}}is") + value = "".join(_iter_text(is_node)) if is_node is not None else "" + if value != "": + cells[(r, c)] = value + max_row = max(max_row, r) + max_col = max(max_col, c) + + merge_node = root.find(f".//{{{XLSX_NS}}}mergeCells") + if merge_node is not None: + for mc in merge_node.iter(f"{{{XLSX_NS}}}mergeCell"): + ref = mc.get("ref") + if not ref or ":" not in ref: + continue + a, b = ref.split(":") + c1, r1 = _parse_ref(a) + c2, r2 = _parse_ref(b) + max_row = max(max_row, r2) + max_col = max(max_col, c2) + top = cells.get((r1, c1), "") + if not top: + continue + for rr in range(r1, r2 + 1): + for cc in range(c1, c2 + 1): + if (rr, cc) not in cells: + cells[(rr, cc)] = top + + matrix = [ + [cells.get((r, c), "") for c in range(max_col + 1)] + for r in range(max_row + 1) + ] + return matrix, hidden_cols + + +def _read_cell(row, col_idx): + if col_idx < len(row): + return row[col_idx] + return "" + + +def download_schedule(public_url): + try: + resp = requests.get( + "https://cloud-api.yandex.net/v1/disk/public/resources/download", + params={"public_key": public_url}, + timeout=30, + ) + resp.raise_for_status() + href = resp.json()["href"] + file_resp = requests.get(href, timeout=60) + file_resp.raise_for_status() + return file_resp.content + except Exception as exc: + raise ScheduleError(f"Не удалось скачать таблицу: {exc}") from exc + + +def fetch_table_modified(public_url): + try: + resp = requests.get( + "https://cloud-api.yandex.net/v1/disk/public/resources", + params={"public_key": public_url}, + timeout=30, + ) + resp.raise_for_status() + return resp.json().get("modified") + except Exception: + return None + + +def parse_workbook(xlsx_bytes): + try: + zf = zipfile.ZipFile(io.BytesIO(xlsx_bytes)) + except zipfile.BadZipFile as exc: + raise ScheduleError("Скачанный файл не является xlsx") from exc + + shared = [] + try: + shared = _parse_shared_strings(zf.read("xl/sharedStrings.xml")) + except KeyError: + pass + + rels_root = ET.fromstring(zf.read("xl/_rels/workbook.xml.rels")) + rel_map = {} + for rel in rels_root: + rid = rel.get("Id") + target = rel.get("Target") + if rid and target: + rel_map[rid] = target + + wb_root = ET.fromstring(zf.read("xl/workbook.xml")) + sheets = {} + for sheet in wb_root.iter(f"{{{XLSX_NS}}}sheet"): + name = sheet.get("name") + rid = sheet.get(f"{{{REL_NS}}}id") + if name and rid and rid in rel_map: + target = rel_map[rid] + if target.startswith("worksheets/"): + matrix, hidden_cols = _parse_sheet(zf.read("xl/" + target), shared) + sheets[name] = {"rows": matrix, "hidden_cols": hidden_cols} + + return sheets + + +def _normalize_header(text): + return text.split("\n", 1)[0].strip() + + +def _excel_serial_to_date(serial): + try: + value = float(str(serial).replace(",", ".")) + except (TypeError, ValueError): + return None + if value <= 0: + return None + if value <= 60: + value -= 1 + return (datetime(1899, 12, 30) + timedelta(days=value)).date() + + +def extract_week_ranges(sheets): + if MAIN_SHEET not in sheets: + return [] + matrix = sheets[MAIN_SHEET]["rows"] + ranges = [] + seen = set() + for row in matrix[1:]: + if not any(_read_cell(row, i) for i in range(4)): + continue + for parity, start_col, end_col in (("ч", 0, 1), ("з", 2, 3)): + start = _excel_serial_to_date(_read_cell(row, start_col)) + end = _excel_serial_to_date(_read_cell(row, end_col)) + if start and end: + key = (parity, start) + if key not in seen: + seen.add(key) + ranges.append({"parity": parity, "start": start, "end": end}) + return sorted(ranges, key=lambda w: w["start"]) + + +def current_parity(week_ranges, today=None): + today = today or date.today() + for week in week_ranges: + if week["start"] <= today <= week["end"]: + return week["parity"] + return None + + +def parse_schedule(sheets): + if MAIN_SHEET not in sheets: + raise ScheduleError(f"Лист «{MAIN_SHEET}» не найден в таблице") + sheet = sheets[MAIN_SHEET] + matrix = sheet["rows"] + hidden_cols = sheet["hidden_cols"] + + header = matrix[0] + group_columns = {} + seen_names = set() + group_re = re.compile(r"^[А-ЯЁ]{2,4}-\d") + for idx, cell in enumerate(header): + if idx in hidden_cols: + continue + name = _normalize_header(cell) + if name and group_re.match(name) and name not in seen_names: + group_columns[idx] = name + seen_names.add(name) + + groups = {} + for col, name in group_columns.items(): + header_text = _read_cell(header, col) + semester = None + if "\n" in header_text: + second = header_text.split("\n", 1)[1].strip() + m = re.search(r"(\d{1,2}\.\d{2}\.\d{2})", second) + if m: + semester = m.group(1) + groups[name] = {"semester_until": semester, "days": {}} + + day_periods = {} + cur_day = None + period = None + cur_time = "" + parity = None + + for row in matrix[1:]: + has_dates = any(_read_cell(row, i) for i in range(4)) + if not has_dates: + continue + + day = _read_cell(row, 4) + if day: + normalized = day.strip().lower() + if normalized in DAYS: + if normalized != cur_day: + cur_day = normalized + parity = None + if cur_day is None: + continue + + period_cell = _read_cell(row, 5) + if period_cell: + lines = [ln.strip() for ln in period_cell.split("\n") if ln.strip()] + m = _PERIOD_RE.match(lines[0]) + if m: + period = int(m.group(1)) + time_parts = [m.group(2).strip()] + time_parts += lines[1:] + cur_time = " ".join(p for p in time_parts if p) + cur_time = re.sub(r"\s*[-–—]\s*", "-", cur_time) + day_periods.setdefault(cur_day, {}).setdefault(period, cur_time) + + if period is None: + continue + + parity = "з" if parity == "ч" else "ч" + + for col, group_name in group_columns.items(): + cell_text = _read_cell(row, col) + if cell_text: + days_map = groups[group_name]["days"].setdefault(cur_day, {}) + slot = days_map.setdefault(period, {"time": cur_time, "ч": None, "з": None}) + slot[parity] = cell_text + + for group_name in groups: + for day, periods in day_periods.items(): + days_map = groups[group_name]["days"] + for period, time in periods.items(): + days_map.setdefault(day, {})[period] = days_map.get(day, {}).get( + period, {"time": time, "ч": None, "з": None} + ) + days_map[day] = dict(sorted(days_map[day].items())) + + return groups + + +_MARKER_RE = re.compile(r"к\.в\.?\s*-?\s*\d*\s*", re.IGNORECASE) + + +_ROOM_LINE_RE = re.compile(r"ауд\.\s*[\w\-]+", re.IGNORECASE) +_SUBJECT_START_RE = re.compile(r"^[А-ЯЁA-Z]") + + +def _split_lesson_blocks(cell_text): + lines = cell_text.replace("\r\n", "\n").replace("\r", "\n").split("\n") + blocks = [] + current = [] + prev_blank = True + prev_room_line = False + for raw in lines: + line = raw.strip() + if not line: + prev_blank = True + continue + starts_marker = bool(_MARKER_RE.match(line)) + room_line = bool(_ROOM_LINE_RE.search(line)) + subject_start = bool(_SUBJECT_START_RE.match(line)) and not any( + w in line for w in ("не будет", "отменяется", "переносится") + ) + if current and (prev_blank or starts_marker or (prev_room_line and subject_start)): + blocks.append(current) + current = [] + current.append(line) + prev_blank = False + prev_room_line = room_line + if current: + blocks.append(current) + return blocks + + +def _lesson(marker, subject, teacher, room): + room = re.sub(r"ауд\.?\s*", "ауд. ", room, flags=re.IGNORECASE) + room = re.sub(r"\s{2,}", " ", room).strip() + return { + "marker": marker, + "subject": subject, + "teacher": teacher, + "room": room, + } + + +def _split_subject_teacher(head): + head = head.strip() + name_re = re.compile( + r"([А-ЯЁ][а-яё-]+(?:\s+[А-ЯЁ]\.[А-ЯЁ]\.?)+)(\s*\([^)]*\))*$" + ) + name_m = name_re.search(head) + if name_m: + teacher = name_m.group(1).strip() + subject = (head[: name_m.start()] + (name_m.group(2) or "")).strip() + return subject, teacher + words = head.split() + if len(words) >= 2 and words[-1][:1].isupper(): + return " ".join(words[:-1]), words[-1] + return head, "" + + +def _parse_inline(text, marker): + text = text.strip() + m = None + for match in re.finditer(r"ауд\.?\s*[\d\-]+\w*", text, re.IGNORECASE): + m = match + if not m: + return _lesson(marker, text, "", "") + room = m.group(0).strip() + head = text[: m.start()].strip() + subject, teacher = _split_subject_teacher(head) + return _lesson(marker, subject, teacher, room) + + +def _parse_lesson(lines): + lines = [ + ln.replace(RED_OPEN, "").replace(RED_CLOSE, "").strip() + for ln in lines + if ln.strip() + ] + if not lines: + return None + + marker = None + m = re.match(r"^(к\.в\.?\s*-?\s*\d*)\s*(.*)$", lines[0], re.IGNORECASE) + if m: + marker = m.group(1).strip() + rest_head = m.group(2).strip() + if rest_head: + lines[0] = rest_head + else: + lines = lines[1:] + if not lines: + return None + + if len(lines) == 1: + return _parse_inline(lines[0], marker) + + if len(lines) == 2: + second = lines[1] + if re.search(r"(ауд\.|http|зал|каб\.|онлайн)", second, re.IGNORECASE): + subject, teacher = _split_subject_teacher(lines[0]) + return _lesson(marker, subject, teacher, second) + if re.search(r"\b[А-ЯЁ]\.[А-ЯЁ]\.?$", second): + return _lesson(marker, lines[0], second, "") + return _lesson(marker, lines[0], "", second) + + # >=3 строк: ищем строку аудитории, преподаватель — строка перед ней; + # строки после аудитории (примечания) не ломают разбор + room_idx = None + for i in range(len(lines) - 1, -1, -1): + if _ROOM_LINE_RE.match(lines[i]): + room_idx = i + break + if room_idx is None: + room = lines[-1] + teacher = lines[-2] + subject = " ".join(lines[:-2]) + return _lesson(marker, subject, teacher, room) + + room = lines[room_idx] + before = lines[:room_idx] + teacher = "" + teacher_idx = None + for i in range(len(before) - 1, -1, -1): + cand = before[i] + if _looks_like_teacher_line(cand): + teacher_idx = i + teacher = cand + break + if teacher_idx is not None: + subject = " ".join(before[:teacher_idx]) + else: + subject = " ".join(before) + return _lesson(marker, subject, teacher, room) + + +def _unwrap_parens(text): + text = text.strip() + if text.startswith("(") and text.endswith(")"): + inner = text[1:-1].strip() + if inner: + return inner + return text + + +def _collect_notes(text): + notes = [] + for m in re.findall(r"⟪([^⟫]*)⟫", text): + frag = m.strip() + if frag: + notes.append((_unwrap_parens(frag), True)) + plain = re.sub(r"⟪[^⟫]*⟫", "", text) + for m in re.finditer(r"\(([^()]*)\)", plain): + inner = m.group(1).strip() + if not inner: + continue + if re.search(r"[А-ЯЁ]\.[А-ЯЁ]\.?", inner): + continue + if not re.search(r"[а-яёА-ЯЁa-zA-Z]", inner): + continue + notes.append((_unwrap_parens(inner), False)) + return notes + + +def _strip_note_markers(value): + return value.replace(RED_OPEN, "").replace(RED_CLOSE, "") + + +def _split_lessons(cell_text): + lessons = [] + last_marker = None + for block in _split_lesson_blocks(cell_text): + block_text = "\n".join(block) + lesson = _parse_lesson(block) + if lesson is None: + continue + for key in ("marker", "subject", "teacher", "room"): + if lesson[key]: + lesson[key] = _strip_note_markers(lesson[key]) + fields = " ".join( + x for x in (lesson["subject"], lesson["teacher"], lesson["room"]) if x + ) + keep = [] + seen_notes = set() + for note_text, is_red in _collect_notes(block_text): + if not note_text or note_text in seen_notes: + continue + if not is_red and note_text in fields: + continue + seen_notes.add(note_text) + keep.append(note_text) + lesson["note"] = "\n".join(keep) + if lesson["marker"]: + last_marker = lesson["marker"] + elif last_marker: + lesson["marker"] = last_marker + lessons.append(lesson) + return lessons + + +def build_group_schedule(data, group_name): + group = data.get(group_name) + if group is None: + return None + out = { + "group": group_name, + "semester_until": group.get("semester_until"), + "days": {}, + } + for day, periods in group["days"].items(): + out["days"][day] = {} + for period, slot in periods.items(): + entry = {"time": slot.get("time", "")} + for parity in ("ч", "з"): + raw = slot.get(parity) + if raw: + entry[parity] = {"raw": raw, "lessons": _split_lessons(raw)} + else: + entry[parity] = None + out["days"][day][str(period)] = entry + return out + + +_ROOM_RE = re.compile(r"ауд\.\s*([\w\-]+)", re.IGNORECASE) + + +def _iter_lessons(schedule_data): + for group, gdata in schedule_data.items(): + for day, periods in gdata.get("days", {}).items(): + for period, slot in periods.items(): + time = slot.get("time", "") + for parity in ("ч", "з"): + raw = slot.get(parity) + if not raw: + continue + for lesson in _split_lessons(raw): + yield group, day, period, time, parity, lesson + + +def _is_name(text): + if not text: + return False + if text.startswith(("(", "—", "–", "-", "«", '"')): + return False + if any(w in text for w in ("не будет", "отменяется", "переносится", "пара будет")): + return False + if not re.search(r"[А-ЯЁа-яё]", text): + return False + if re.search(r"\d", text) and not re.search(r"[А-ЯЁ]\.[А-ЯЁ]", text): + return False + return True + + +_NAME_RE = re.compile(r"[А-ЯЁ][а-яё-]+(?:\s+[А-ЯЁ]\.[А-ЯЁ]\.?)+") + + +def _extract_teachers(text): + return [m.strip() for m in _NAME_RE.findall(text) if m.strip()] + + +def _is_org(text): + if not _is_name(text): + return False + if any(ch in text for ch in "()"): + return False + if re.search(r"[А-ЯЁ]\.[А-ЯЁ]", text): + return False + return True + + +_ORG_LINE_RE = re.compile( + r"[А-ЯЁ][A-Za-zА-ЯЁа-яё-]*(?:\s+[А-ЯЁ][A-Za-zА-ЯЁа-яё-]+)?" +) + + +def _looks_like_teacher_line(text): + if re.search(r"[А-ЯЁ]\.[А-ЯЁ]\.?", text): + return True + return bool(_ORG_LINE_RE.fullmatch(text)) and len(text) <= 40 + + +def _merge_entry(slot_list, entry_key, fields): + for item in slot_list: + if all(item.get(k) == v for k, v in entry_key.items()): + return item + item = dict(entry_key) + item["groups"] = [] + item.update(fields) + slot_list.append(item) + return item + + +def build_teacher_room_index(schedule_data): + teachers = {} + rooms = {} + + for group, day, period, time, parity, lesson in _iter_lessons(schedule_data): + subject = lesson["subject"] + room = lesson["room"].strip() + + if lesson["teacher"]: + teachers_found = _extract_teachers(lesson["teacher"]) + if not teachers_found and _is_org(lesson["teacher"]): + teachers_found = [lesson["teacher"].strip()] + + for teacher in teachers_found: + days_map = teachers.setdefault(teacher, {}).setdefault(day, {}) + slot = days_map.setdefault( + str(period), {"time": time, "ч": [], "з": []} + ) + item = _merge_entry( + slot[parity], + {"subject": subject, "room": room}, + {"marker": lesson["marker"]}, + ) + if group not in item["groups"]: + item["groups"].append(group) + + display_teacher = ", ".join(teachers_found) if teachers_found else "" + else: + display_teacher = "" + + room_m = _ROOM_RE.match(room) + if room_m: + label = "ауд. " + room_m.group(1) + days_map = rooms.setdefault(label, {}).setdefault(day, {}) + slot = days_map.setdefault( + str(period), {"time": time, "ч": [], "з": []} + ) + item = _merge_entry( + slot[parity], + {"subject": subject, "teacher": display_teacher}, + {"marker": lesson["marker"]}, + ) + if group not in item["groups"]: + item["groups"].append(group) + + return teachers, rooms + + +def sort_rooms(room_names): + def key(name): + m = re.search(r"(\d+)", name) + return (0, int(m.group(1))) if m else (1, name) + + return sorted(room_names, key=key) + + +def parse_rooms_info(sheets): + """Аудитории: номер (заголовок) -> оснащение/вместимость (2-я строка).""" + name = "аудитории" + if name not in sheets: + return {} + rows = sheets[name]["rows"] + if len(rows) < 2: + return {} + header = rows[0] + desc = rows[1] + info = {} + for col in range(6, max(len(header), len(desc))): + raw = _read_cell(header, col) + m = re.search(r"(\d+)", str(raw)) + if not m: + continue + number = int(m.group(1)) + text = " ".join( + line.strip() + for line in _read_cell(desc, col).split("\n") + if line.strip() + ) + label = f"ауд. {number}" + info[label] = {"room": label, "info": text} + return info + + +def _initials_from_parts(first, patronymic): + initials = "" + if first: + initials += first[0].upper() + "." + if patronymic: + initials += patronymic[0].upper() + "." + return initials + + +def parse_teachers_full(sheets): + """Преподаватели: короткое «Фамилия И.О.» -> полное ФИО (3-я строка-заголовок).""" + name = "Преподаватели" + if name not in sheets: + return {} + rows = sheets[name]["rows"] + if len(rows) < 2: + return {} + header = rows[1] + result = {} + for col in range(6, len(header)): + value = _read_cell(header, col) + parts = [p.strip() for p in re.split(r"[\s\n]+", value) if p.strip()] + if not parts: + continue + surname = parts[0] + first = parts[1] if len(parts) > 1 else "" + patronymic = parts[2] if len(parts) > 2 else "" + short = f"{surname} {_initials_from_parts(first, patronymic)}".strip() + full = " ".join(parts) + if short and full: + result.setdefault(short, full) + return result + + +def build_teacher_schedule(teachers, name): + if name not in teachers: + return None + return {"name": name, "days": teachers[name]} + + +def build_day_times(schedule_data): + day_times = {} + for gdata in schedule_data.values(): + for day, periods in gdata.get("days", {}).items(): + target = day_times.setdefault(day, {}) + for period, slot in periods.items(): + target.setdefault(str(period), slot.get("time", "")) + return day_times + + +def build_room_schedule(rooms, label, day_times=None): + if label not in rooms: + return None + data = rooms[label] + out = {"room": label, "days": {}} + day_times = day_times or {} + all_days = {*data.keys(), *day_times.keys()} + for day in sorted(all_days, key=lambda d: DAYS.index(d) if d in DAYS else 99): + out["days"][day] = {} + times = day_times.get(day, {}) + periods = {*data.get(day, {}).keys(), *times.keys()} + for period in sorted(periods, key=lambda p: int(p)): + entry = {"time": times.get(period, data.get(day, {}).get(period, {}).get("time", ""))} + occupied = data.get(day, {}).get(period, {}) + for parity in ("ч", "з"): + lessons = occupied.get(parity) or [] + entry[parity] = lessons if lessons else None + out["days"][day][str(period)] = entry + return out diff --git a/src/frontend/main.py b/src/frontend/main.py new file mode 100644 index 0000000..be8fd5a --- /dev/null +++ b/src/frontend/main.py @@ -0,0 +1,93 @@ +import os +from pathlib import Path + +import requests +import uvicorn +from dotenv import load_dotenv +from fastapi import FastAPI, HTTPException, Query +from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles + +load_dotenv(Path(__file__).parent / ".env") + +SCHEDULE_API = os.getenv("SCHEDULE_API", "http://127.0.0.1:8000").rstrip("/") +STATIC_DIR = Path(__file__).parent / "static" + +app = FastAPI(title="Расписание — фронт") + + +def _proxy(path: str, params: dict): + try: + resp = requests.get(f"{SCHEDULE_API}/{path}", params=params, timeout=30) + except requests.RequestException as exc: + raise HTTPException(status_code=502, detail=f"Бэкенд недоступен: {exc}") + if resp.status_code != 200: + try: + detail = resp.json().get("detail", resp.text) + except Exception: + detail = resp.text + raise HTTPException(status_code=resp.status_code, detail=detail) + return resp.json() + + +@app.get("/api/groups") +def api_groups(force: bool = Query(False)): + return _proxy("groups", {"force": force}) + + +@app.get("/api/schedule/{group}") +def api_schedule(group: str, force: bool = Query(False)): + return _proxy(f"schedule/{group}", {"force": force}) + + +@app.get("/api/teachers") +def api_teachers(force: bool = Query(False)): + return _proxy("teachers", {"force": force}) + + +@app.get("/api/rooms") +def api_rooms(force: bool = Query(False)): + return _proxy("rooms", {"force": force}) + + +@app.get("/api/teacher/{name}") +def api_teacher(name: str, force: bool = Query(False)): + return _proxy(f"teacher/{name}", {"force": force}) + + +@app.get("/api/room/{label}") +def api_room(label: str, force: bool = Query(False)): + return _proxy(f"room/{label}", {"force": force}) + + +@app.get("/api/rooms-free") +def api_rooms_free(day: str, period: int, force: bool = Query(False)): + return _proxy("rooms-free", {"day": day, "period": period, "force": force}) + + +@app.get("/api/rooms-day") +def api_rooms_day(day: str, force: bool = Query(False)): + return _proxy("rooms-day", {"day": day, "force": force}) + + +@app.get("/api/week") +def api_week(force: bool = Query(False)): + return _proxy("week", {"force": force}) + + +@app.get("/api/status") +def api_status(force: bool = Query(False)): + return _proxy("status", {"force": force}) + + +@app.get("/") +def index(): + return FileResponse(STATIC_DIR / "index.html") + + +app.mount("/", StaticFiles(directory=STATIC_DIR, html=True), name="static") + + +if __name__ == "__main__": + uvicorn.run(app, host="127.0.0.1", port=8080) + diff --git a/src/frontend/static/app.js b/src/frontend/static/app.js new file mode 100644 index 0000000..a63a06e --- /dev/null +++ b/src/frontend/static/app.js @@ -0,0 +1,526 @@ +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"; + +function todayName() { + return RUSSIAN_DAYS[new Date().getDay()]; +} + +function enc(value) { + return encodeURIComponent(value); +} + +async function fetchJSON(url) { + const resp = await fetch(url); + 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"); + 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(force = false) { + const value = groupSelect.value; + if (!value) return; + localStorage.setItem(MODES[mode].valueKey, value); + scheduleEl.innerHTML = ""; + errorEl.classList.add("hidden"); + loadingEl.classList.remove("hidden"); + try { + if (mode === "groups") { + const [data, status] = await Promise.all([ + fetchJSON(`/api/schedule/${enc(value)}${force ? "?force=1" : ""}`), + loadStatus(), + ]); + renderGroupSchedule(data, status); + } else { + const [data, status] = await Promise.all([ + fetchJSON(`/api/teacher/${enc(value)}`), + loadStatus(), + ]); + renderTeacher(data, status); + } + } catch (err) { + errorEl.textContent = "Ошибка: " + err.message; + errorEl.classList.remove("hidden"); + } finally { + 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 (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 active = parity === currentParity ? " active" : ""; + 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 ` +
+
+ ${PARITY_LABEL[parity]} + ${entry.marker ? `${escapeHTML(entry.marker)}` : ""} + ${escapeHTML(entry.subject)} +
+ ${details.length ? `
${escapeHTML(details.join(" · "))}
` : ""} + ${entry.note ? `
${escapeHTML(entry.note)}
` : ""} +
`; +} + +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 details = [lesson.teacher, lesson.room].filter(Boolean).join(" · "); + return ` +
+
+ ${label} + ${lesson.marker ? `${escapeHTML(lesson.marker)}` : ""} + ${escapeHTML(lesson.subject)} +
+ ${details ? `
${escapeHTML(details)}
` : ""} + ${lesson.note ? `
${escapeHTML(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 chHtml = (slot.ч || []).map((entry) => { hasAny = true; return entityLessonHTML("ч", entry); }).join(""); + const znHtml = (slot.з || []).map((entry) => { hasAny = true; return entityLessonHTML("з", entry); }).join(""); + if (!chHtml && !znHtml) return; + const inner = `
${parityColHTML("Числитель", chHtml || OKNO_HTML)}${parityColHTML("Знаменатель", znHtml || OKNO_HTML)}
`; + addPeriodRow(block, num, slot.time, inner); + }); + daysContainer.appendChild(block); + }); + + if (!hasAny) { + daysContainer.innerHTML = ""; + daysContainer.appendChild(Object.assign(document.createElement("div"), { + className: "empty-day", + textContent: "Занятий нет", + })); + } +} + +/* ---------- аудитории: занятость по выбранному дню ---------- */ + +const FREE_WEEKDAYS = ["понедельник", "вторник", "среда", "четверг", "пятница", "суббота"]; + +async function renderRoomsView(day) { + const chosenDay = FREE_WEEKDAYS.includes(day) ? day : (FREE_WEEKDAYS.includes(todayName()) ? todayName() : "понедельник"); + scheduleEl.innerHTML = ""; + errorEl.classList.add("hidden"); + loadingEl.classList.remove("hidden"); + try { + const status = await loadStatus(); + scheduleEl.appendChild(Object.assign(document.createElement("div"), { + className: "schedule-title", + textContent: "Занятость аудиторий", + })); + const top = statusRowHTML(status); + if (top) scheduleEl.appendChild(top); + + const wrap = document.createElement("div"); + wrap.className = "rooms-wrap"; + scheduleEl.appendChild(wrap); + + const data = await fetchJSON(`/api/rooms-day?day=${enc(chosenDay)}`); + wrap.appendChild(roomsDayTable(data)); + } catch (err) { + errorEl.textContent = "Ошибка: " + err.message; + errorEl.classList.remove("hidden"); + } finally { + loadingEl.classList.add("hidden"); + } +} + +function roomsDayTable(data) { + const periodNums = []; + data.rooms.forEach((r) => { + Object.keys(r.periods || {}).forEach((p) => { + if (!periodNums.includes(p)) periodNums.push(p); + }); + }); + periodNums.sort((a, b) => Number(a) - Number(b)); + return buildRoomsList(data, periodNums); +} + +function buildRoomsList(data, periodNums) { + const list = document.createElement("div"); + list.className = "rooms-list"; + data.rooms.forEach((room) => { + const card = document.createElement("div"); + card.className = "room-card"; + const head = document.createElement("div"); + head.className = "day-head"; + head.innerHTML = `
${escapeHTML(room.room)}${room.info ? `
${escapeHTML(room.info)}
` : ""}
`; + card.appendChild(head); + 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 { + const chHtml = chEntries.map((e) => entityLessonHTML("ч", e)).join(""); + const znHtml = znEntries.map((e) => entityLessonHTML("з", e)).join(""); + body.innerHTML = `
${parityColHTML("", chHtml || OKNO_HTML)}${parityColHTML("", znHtml || OKNO_HTML)}
`; + } + row.appendChild(body); + card.appendChild(row); + }); + list.appendChild(card); + }); + return list; +} + +function roomPeriodCell(slot) { + const td = document.createElement("td"); + td.className = "r-cell"; + const all = [...((slot && slot.ч) || []), ...((slot && slot.з) || [])]; + if (!all.length) { + td.classList.add("r-free"); + td.textContent = "окно"; + return td; + } + td.classList.add("r-busy"); + const seen = new Set(); + const lines = []; + all.forEach((e) => { + if (!e.subject || seen.has(e.subject)) return; + seen.add(e.subject); + lines.push(e.subject); + }); + lines.forEach((x) => { + const div = document.createElement("div"); + div.className = "r-subj"; + div.textContent = x; + td.appendChild(div); + }); + td.title = lines.join("; "); + return td; +} + +function escapeHTML(s) { + return s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); +} + +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"); + }); +}); diff --git a/src/frontend/static/img/favicon.png b/src/frontend/static/img/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..bf71d6c93018a9569a4a403abfcda3cdd2b39d1d GIT binary patch literal 315 zcmeAS@N?(olHy`uVBq!ia0vp^CLqkn3?%<=>s$e(6p}rHd>I(3)EF2VS{N990fib~ zFff!FFfhDIU|_JC!N4G1FlSew4N!tDz$e6&q3QVl|NmQ4S4{wlFeZ7syD^!nDO0ySvBZ79jiO)V}-%q_swV+^(AyGVB=P!FG{ ki(`ny<>Ukj)@Gn985b}xw&^Vr2g)#by85}Sb4q9e0F%W~eE + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/frontend/static/img/owl.svg b/src/frontend/static/img/owl.svg new file mode 100644 index 0000000..28916c9 --- /dev/null +++ b/src/frontend/static/img/owl.svg @@ -0,0 +1,61 @@ + +image/svg+xml diff --git a/src/frontend/static/index.html b/src/frontend/static/index.html new file mode 100644 index 0000000..dd32b27 --- /dev/null +++ b/src/frontend/static/index.html @@ -0,0 +1,58 @@ + + + + + + Расписание · Факультет ИВТ ЯрГУ + + + + + + + +
+ + +
+ + +
+
+
+ + + + diff --git a/src/frontend/static/style.css b/src/frontend/static/style.css new file mode 100644 index 0000000..8ff0c47 --- /dev/null +++ b/src/frontend/static/style.css @@ -0,0 +1,683 @@ +:root { + --bg: #f1f5f9; + --surface: #ffffff; + --surface-2: #f7fafc; + --surface-3: #eef2f8; + --border: #dde5ef; + --border-2: #c3d1e2; + --text: #17223a; + --muted: #57677f; + --muted-2: #8c9bb0; + + --nav-1: #1b2dd8; + --nav-2: #167cf2; + --accent: #0241e3; + --accent-blue: #1f4fd0; + --accent-red: #d01838; + --accent-red-bright: #e61d43; + --accent-blue-bg: #e5ecfc; + --on-accent: #ffffff; + + --error-bg: #fbe9ea; + --error-border: #f3c1c5; + --error-text: #b00d15; + --shadow: rgba(10, 50, 110, 0.12); + + --badge-ch-bg: #e3ebfc; + --badge-ch-text: #1f4fd0; + --badge-zn-bg: #fbe3e7; + --badge-zn-text: #c40f30; + --badge-kv-bg: #edf1f7; + --badge-kv-text: #43536b; + --badge-always-bg: #e6edf6; + --badge-always-text: #2e3d55; + --badge-ch-active-bg: linear-gradient(90deg, #1b2dd8, #167cf2); + --badge-ch-active-text: #ffffff; + --badge-zn-active-bg: #e61d43; + --badge-zn-active-text: #ffffff; +} + +[data-theme="dark"] { + --bg: #0a1120; + --surface: #111a2e; + --surface-2: #16223a; + --surface-3: #1d2e4c; + --border: #25375a; + --border-2: #3a4f78; + --text: #e7eef8; + --muted: #96adca; + --muted-2: #647e9f; + + --nav-1: #1d2f72; + --nav-2: #1358b0; + --accent: #5ba3f7; + --accent-blue: #86b8ff; + --accent-red: #e96a7e; + --accent-red-bright: #ff6b7f; + --accent-blue-bg: #1a2d52; + --on-accent: #ffffff; + + --error-bg: #4d1c22; + --error-border: #7c2a34; + --error-text: #ffc2c9; + --shadow: rgba(0, 0, 0, 0.45); + + --badge-ch-bg: #1a2c55; + --badge-ch-text: #8cb8ff; + --badge-zn-bg: #54222d; + --badge-zn-text: #ff9dae; + --badge-kv-bg: #1e2c43; + --badge-kv-text: #b6c6da; + --badge-always-bg: #23314d; + --badge-always-text: #d9e3f0; + --badge-ch-active-bg: #3d86f2; + --badge-ch-active-text: #ffffff; + --badge-zn-active-bg: #ff6479; + --badge-zn-active-text: #ffffff; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + font-family: Roboto, -apple-system, "Segoe UI", Arial, sans-serif; + font-size: 16px; + line-height: 1.5; + background: var(--bg); + color: var(--text); + -webkit-font-smoothing: antialiased; +} + +a { color: var(--accent); text-decoration: none; } +a:hover { color: var(--accent-red); } + +/* ---- Верхняя белая полоса с цветной совой (как на сайте) ---- */ +.topbar { + background: var(--surface); + border-bottom: 1px solid var(--border); + padding: 8px 20px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + position: sticky; + top: 0; + z-index: 20; +} + +.topbar-brand { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; +} + +.topbar-logo { + width: 46px; + height: 46px; + flex-shrink: 0; +} + +.topbar-text { display: flex; flex-direction: column; line-height: 1.25; min-width: 0; } + +.topbar-university { + color: var(--text); + font-size: 14px; + font-weight: 700; +} +.topbar-university:hover { color: var(--accent-red); } + +.topbar-faculty { + color: var(--muted); + font-size: 13px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.topbar-faculty:hover { color: var(--accent-red); } + +.theme-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + padding: 0; + border: 1px solid var(--border-2); + background: var(--surface); + color: var(--muted); + cursor: pointer; + flex-shrink: 0; +} + +.theme-toggle:hover { color: var(--accent); border-color: var(--accent); } +.theme-toggle .icon { width: 18px; height: 18px; } + +.icon-sun { display: none; } +.icon-moon { display: block; } + +[data-theme="dark"] .icon-sun { display: block; } +[data-theme="dark"] .icon-moon { display: none; } + +/* ---- Двухколоночный layout: синяя панель + контент ---- */ +.layout { + display: flex; + align-items: stretch; + min-height: calc(100vh - 63px); +} + +.side { + width: 300px; + flex-shrink: 0; + background: linear-gradient(to bottom, var(--nav-1), var(--nav-2)); + color: var(--on-accent); + display: flex; + flex-direction: column; + padding: 26px 22px 22px; + position: sticky; + top: 63px; + align-self: flex-start; + height: calc(100vh - 63px); +} + +.side-head { text-align: center; padding-bottom: 18px; border-bottom: 1px solid rgba(255, 255, 255, 0.22); } + +.side-logo-link { display: inline-block; } +.side-logo { width: 92px; height: 92px; } + +.side-name { + margin-top: 12px; + font-size: 17px; + font-weight: 600; + line-height: 1.25; +} + +.side-nav { + padding: 18px 0; + flex: 1; +} + +.side-menu { + display: flex; + flex-direction: column; + gap: 2px; + margin-bottom: 16px; +} + +.side-menu a { + display: block; + color: rgba(255, 255, 255, 0.9); + font-size: 15px; + font-weight: 600; + line-height: 1.3; + padding: 8px 10px; + border-left: 3px solid transparent; +} + +.side-menu a:hover { color: #ffffff; background: rgba(0, 0, 0, 0.12); } + +.side-menu a.active { + color: #ffffff; + background: rgba(0, 0, 0, 0.2); + border-left-color: #ffffff; +} + +.side-label { + text-transform: uppercase; + font-size: 12px; + letter-spacing: 1px; + color: rgba(255, 255, 255, 0.8); + margin-bottom: 8px; +} + +#group-select { + width: 100%; + font-size: 16px; + font-weight: 500; + padding: 9px 10px; + border: none; + background: #ffffff; + color: #141c2b; + cursor: pointer; +} + +#group-select option { background: #ffffff; color: #141c2b; } + +[data-theme="dark"] #group-select { + background: rgba(10, 19, 40, 0.6); + color: #ffffff; + border: 1px solid rgba(255, 255, 255, 0.35); +} + +[data-theme="dark"] #group-select option { + background: var(--surface-3); + color: var(--text); +} + +.side-footer { + border-top: 1px solid rgba(255, 255, 255, 0.22); + padding-top: 14px; + display: flex; + flex-direction: column; + gap: 6px; + font-size: 14px; +} + +.side-footer a { color: rgba(255, 255, 255, 0.92); } +.side-footer a:hover { color: #ffffff; text-decoration: underline; } + +main { + flex: 1; + min-width: 0; + padding: 26px 28px 40px; +} + +.schedule { max-width: 900px; } + +.schedule-title { + font-size: 22px; + font-weight: 700; + color: var(--text); + margin-bottom: 2px; +} + +.schedule-title .badge { margin-left: 10px; font-size: 13px; vertical-align: middle; } + +.schedule-meta { color: var(--muted); font-size: 14px; margin-bottom: 10px; } + +.schedule-status { + display: flex; + justify-content: flex-start; + gap: 10px; + flex-wrap: wrap; + margin-bottom: 18px; +} + +.status-chip { + flex: 1 1 0; + min-width: 170px; + min-height: 54px; + display: flex; + flex-direction: column; + justify-content: center; + gap: 3px; + padding: 8px 12px; + background: var(--surface-2); + border: 1px solid var(--border); + box-sizing: border-box; +} + +.status-chip.current-week { align-items: flex-start; } + +.status-label { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.6px; + color: var(--muted); +} + +.status-value { + font-size: 14px; + font-weight: 600; + color: var(--text); +} + +.current-week .badge { margin-right: 0; font-size: 12px; } + +.day-block { + background: var(--surface); + border: 1px solid var(--border); + margin-bottom: 14px; +} + +.day-block.today { + border-color: var(--accent-red-bright); + box-shadow: inset 3px 0 0 var(--accent-red-bright); +} + +.day-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 14px; + border-bottom: 1px solid var(--border); + background: var(--surface-2); +} + +.day-title { font-size: 15px; font-weight: 700; text-transform: capitalize; color: var(--text); } + +.today-badge { + background: var(--accent-red-bright); + color: #ffffff; + font-size: 11px; + font-weight: 700; + padding: 2px 8px; + letter-spacing: 0.4px; +} + +.period { + padding: 8px 14px; + display: grid; + grid-template-columns: 96px 1fr; + gap: 14px; + border-bottom: 1px solid var(--border); +} + +.period:last-child { border-bottom: none; } + +.period-num { font-weight: 700; color: var(--text); } + +.period-time { font-size: 13px; color: var(--muted); margin-top: 2px; } + +.parities { + display: flex; + gap: 10px; + align-items: stretch; +} + +.parity-col { + flex: 1 1 0; + min-width: 0; + display: flex; + flex-direction: column; + gap: 6px; +} + +.parity-col .lesson { + margin-bottom: 0; + flex: 1 1 0; + display: flex; + flex-direction: column; + justify-content: center; +} + +.parity-col .window { justify-content: center; text-align: center; } + +@media (max-width: 1023px) { + .parities { flex-direction: column; } +} + +.lesson { + border: 1px solid var(--border); + padding: 7px 11px; + background: var(--surface-2); +} + +.lesson:hover { border-color: var(--accent); } + +.lesson .subject { font-weight: 700; font-size: 14px; } + +.lesson .details { font-size: 13px; color: var(--muted); margin-top: 1px; } + +.badge { + display: inline-block; + font-size: 11px; + font-weight: 700; + padding: 2px 8px; + margin-right: 6px; + vertical-align: 1px; +} + +.badge-ch { background: var(--badge-ch-bg); color: var(--badge-ch-text); } +.badge-zn { background: var(--badge-zn-bg); color: var(--badge-zn-text); } +.badge-kv { background: var(--badge-kv-bg); color: var(--badge-kv-text); } +.badge-always { background: var(--badge-always-bg); color: var(--badge-always-text); } + +.badge-ch.active { background: var(--badge-ch-active-bg); color: var(--badge-ch-active-text); } +.badge-zn.active { background: var(--badge-zn-active-bg); color: var(--badge-zn-active-text); } + +.lesson.full { grid-column: 1 / -1; } + +.empty-day { color: var(--muted-2); font-size: 14px; padding: 10px 14px; } + +.lesson.empty { + grid-column: 1 / -1; + text-align: center; + color: var(--muted); + background: var(--surface-3); + border: 1px dashed var(--border-2); + font-weight: 600; + padding: 10px; +} + +.window { + border-style: dashed; + color: var(--muted-2); + background: var(--surface); + text-align: center; +} + +/* ---- поиск свободной аудитории ---- */ +.free-widget { + background: var(--surface-2); + border: 1px solid var(--border); + padding: 12px 14px; + margin-bottom: 18px; +} + +.free-title { + font-size: 16px; + font-weight: 700; + margin-bottom: 8px; +} + +.free-controls { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + margin-bottom: 10px; +} + +.free-hint { font-size: 13px; color: var(--muted); } + +.free-controls select { + font-size: 14px; + padding: 6px 8px; + border: 1px solid var(--border-2); + background: var(--surface); + color: var(--text); +} + +.free-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 8px; +} + +.free-item { + text-align: left; + display: flex; + flex-direction: column; + gap: 2px; + padding: 8px 10px; + border: 1px solid var(--border); + background: var(--surface); + color: var(--text); + cursor: pointer; + font-family: inherit; +} + +.free-item:hover { border-color: var(--accent); } + +.free-room { font-weight: 700; } + +.free-info { font-size: 12px; color: var(--muted); } + +.free-loading, .free-none { color: var(--muted); font-size: 14px; padding: 6px 0; } + +.error { + background: var(--error-bg); + color: var(--error-text); + border: 1px solid var(--error-border); + padding: 12px 16px; + margin-bottom: 16px; +} + +.loading { color: var(--muted); text-align: center; padding: 30px; } + +.hidden { display: none; } + +/* ---- Мобильная версия: панель сверху ---- */ +@media (max-width: 860px) { + .layout { flex-direction: column; } + + .side { + width: 100%; + position: static; + height: auto; + padding: 14px 16px; + flex-direction: row; + align-items: center; + gap: 14px; + flex-wrap: wrap; + } + + .side-head { + text-align: left; + display: flex; + align-items: center; + gap: 10px; + padding-bottom: 0; + border-bottom: none; + } + + .side-logo { width: 52px; height: 52px; } + .side-name { margin-top: 0; font-size: 15px; } + .side-nav { padding: 0; flex: 1; min-width: 180px; } + .side-label { display: none; } + .side-footer { display: none; } + + .side-menu { + flex-direction: row; + flex-wrap: wrap; + margin-bottom: 0; + } + .side-menu a { + border-left: none; + border-bottom: 2px solid transparent; + padding: 6px 8px; + font-size: 13px; + } + .side-menu a.active { border-bottom-color: #ffffff; } + + main { padding: 16px 12px 30px; } + .period { grid-template-columns: 80px 1fr; gap: 10px; } +} + +/* ---- занятость аудиторий по дню ---- */ +.rooms-controls { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 14px; +} + +.rooms-controls select { + font-size: 15px; + padding: 6px 10px; + border: 1px solid var(--border-2); + background: var(--surface); + color: var(--text); +} + +.rooms-scroll { overflow-x: auto; } + +.rooms-table { + border-collapse: collapse; + width: 100%; + min-width: 640px; + table-layout: fixed; + background: var(--surface); + font-size: 13px; +} + +.rooms-table th:first-child, +.rooms-table td:first-child { width: 170px; } + +.rooms-table th, +.rooms-table td { + border: 1px solid var(--border); + padding: 6px 9px; + text-align: left; + vertical-align: top; +} + +.rooms-table th { + background: var(--surface-3); + font-size: 12px; + font-weight: 700; + color: var(--text); + white-space: nowrap; +} + +.r-name { + font-weight: 700; + width: 170px; + overflow-wrap: anywhere; +} + +.r-name-info { font-weight: 400; color: var(--muted); font-size: 12px; margin-top: 2px; line-height: 1.35; } + +.r-cell { min-width: 90px; } + +.r-cell.r-free { + background: var(--surface); + color: var(--muted-2); + text-align: center; +} + +.r-cell.r-busy { background: var(--badge-ch-bg); } + +.r-subj { font-weight: 600; color: var(--text); line-height: 1.3; } + +/* ---- аудитории: список карточками (мобильная версия) ---- */ +.rooms-list { display: flex; flex-direction: column; } + +.room-card { + background: var(--surface); + border: 1px solid var(--border); + padding: 14px; + margin-bottom: 12px; +} + +.room-title { line-height: 1.3; } + +.room-info { + color: var(--muted); + font-size: 12px; + margin-top: 2px; +} + +.room-row { + display: grid; + grid-template-columns: 96px 1fr; + gap: 12px; + padding: 10px 0; + border-top: 1px solid var(--border); + align-items: center; +} + +.room-row-num { font-weight: 700; color: var(--text); } + +.room-row-body { min-width: 0; } + +.room-lesson { + font-weight: 600; + font-size: 14px; + color: var(--text); + margin-bottom: 6px; +} + +.room-lesson:last-child { margin-bottom: 0; } + + + +.lesson-note { + color: var(--accent-red-bright); + font-size: 12px; + font-weight: 600; + margin-top: 4px; + white-space: pre-line; +}