schedule-web/PythonApplication1.py.bak_timeago

1425 lines
75 KiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

from playwright.sync_api import sync_playwright
from pathlib import Path
from datetime import datetime, timedelta
from logging.handlers import RotatingFileHandler
import json
import threading
import signal
import sys
import os
import logging
import urllib.request
import urllib.parse
import ssl
from flask import Flask, jsonify, Response, request
os.environ.setdefault("DISPLAY", ":99")
BASE_DIR = Path(__file__).resolve().parent
SESSION_FILE = BASE_DIR / "session.json"
SCHEDULE_FILE = BASE_DIR / "schedule.json"
LOG_FILE = BASE_DIR / "app.log"
CREDENTIALS_FILE = BASE_DIR / ".journal_credentials"
LOGIN_URL = "https://journal.top-academy.ru/ru/auth/login/index"
SCHEDULE_URL = "https://journal.top-academy.ru/ru/main/schedule/page"
API_BASE = "https://journal.top-academy.ru/api/v2/schedule/operations/get-month"
LOG_FORMAT = "%(asctime)s | %(levelname)-7s | %(message)s"
LOG_DATE = "%Y-%m-%d %H:%M:%S"
log = logging.getLogger("schedule")
log.setLevel(logging.DEBUG)
_console = logging.StreamHandler(sys.stdout)
_console.setLevel(logging.INFO)
_console.setFormatter(logging.Formatter(LOG_FORMAT, datefmt=LOG_DATE))
log.addHandler(_console)
_file = RotatingFileHandler(
str(LOG_FILE), maxBytes=5 * 1024 * 1024, backupCount=5, encoding="utf-8",
)
_file.setLevel(logging.DEBUG)
_file.setFormatter(logging.Formatter(LOG_FORMAT, datefmt=LOG_DATE))
log.addHandler(_file)
app = Flask(__name__)
fetch_status = {
"running": False,
"message": "",
"last_error": "",
"last_update": "",
}
status_lock = threading.Lock()
browser_lock = threading.Lock()
shutdown_event = threading.Event()
activity_log = []
activity_lock = threading.Lock()
def log_event(level, text):
"""Append a human-readable event shown on the site (кириллица)."""
try:
with activity_lock:
stamp = datetime.now().strftime("%d.%m %H:%M:%S")
activity_log.append({"time": stamp, "level": level, "text": text})
del activity_log[:-60]
except Exception as e:
log.error("Failed to log event: %s", e)
def set_status(**kwargs):
try:
with status_lock:
fetch_status.update(kwargs)
log.debug("Status updated: %s", kwargs)
except Exception as e:
log.error("Failed to set status: %s", e)
def safe_json_load(path):
try:
if not path.exists():
return []
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
log.debug("Loaded %d items from %s", len(data), path.name)
return data
except json.JSONDecodeError as e:
log.error("Corrupt JSON in %s: %s", path.name, e)
backup = path.with_suffix(".json.bak")
try:
path.rename(backup)
except Exception:
pass
return []
except Exception as e:
log.error("Failed to read %s: %s", path.name, e)
return []
def safe_json_save(path, data):
try:
tmp = path.with_suffix(".json.tmp")
with open(tmp, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
tmp.replace(path)
log.debug("Saved %d items to %s", len(data), path.name)
except Exception as e:
log.error("Failed to save %s: %s", path.name, e)
raise
def get_schedule():
log.info("Starting schedule fetch...")
if not browser_lock.acquire(timeout=5):
raise RuntimeError("Another update is already running.")
try:
return _get_schedule_inner()
finally:
browser_lock.release()
log.info("Lock released.")
def _merge_with_previous(new_items):
"""Merge fresh lessons with the last known cache.
Fresh items win for the same (date, lesson); older items that are absent
from the fresh fetch are kept as a fallback so already-published days are
not lost when the journal temporarily stops returning them.
"""
old_items = safe_json_load(SCHEDULE_FILE) or []
old_keys = {(x.get("date"), x.get("lesson")) for x in old_items}
new_keys = {(x.get("date"), x.get("lesson")) for x in new_items}
dropped = sorted({k for k in old_keys - new_keys})
added = sorted({k for k in new_keys - old_keys})
kept = [x for x in old_items if (x.get("date"), x.get("lesson")) not in new_keys]
merged = new_items + kept
if dropped:
dates_dropped = sorted({d for d, _ in dropped})
log_event("warning", f"Журнал вернул меньше: убраны даты {', '.join(dates_dropped)}, оставлены из кэша ({len(dropped)} ур.)")
if added:
dates_added = sorted({d for d, _ in added})
log_event("ok", f"Обнаружены новые занятия: {', '.join(dates_added)}")
return sorted(merged, key=lambda x: (x.get("date", ""), x.get("lesson", 0)))
def _load_cookies():
if not SESSION_FILE.exists():
return {}
try:
with open(SESSION_FILE, "r", encoding="utf-8") as f:
state = json.load(f)
cookies = {}
for c in state.get("cookies", []):
if "top-academy.ru" in c.get("domain", ""):
cookies[c["name"]] = c["value"]
return cookies
except Exception as e:
log.error("Failed to load cookies: %s", e)
return {}
def _fetch_schedule_direct():
cookies = _load_cookies()
if not cookies:
return None
cookie_header = "; ".join(f"{k}={v}" for k, v in cookies.items())
now = datetime.now()
months = []
for offset in range(-1, 2):
d = now + timedelta(days=offset * 30)
months.append(d.strftime("%Y-%m"))
all_data = []
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
for month in sorted(set(months)):
url = f"{API_BASE}?month={month}"
try:
req = urllib.request.Request(url)
req.add_header("Cookie", cookie_header)
req.add_header("User-Agent", "Mozilla/5.0")
req.add_header("Accept", "application/json")
req.add_header("Referer", SCHEDULE_URL)
resp = urllib.request.urlopen(req, timeout=15, context=ctx)
data = json.loads(resp.read().decode("utf-8"))
if isinstance(data, list) and len(data) > 0:
all_data.extend(data)
log.info("API month %s: %d lessons.", month, len(data))
except urllib.error.HTTPError as e:
if e.code in (401, 403):
log.warning("Auth failed for month %s (HTTP %d).", month, e.code)
return None
except Exception as e:
log.warning("Request error for month %s: %s", month, e)
if not all_data:
return None
return sorted(all_data, key=lambda x: (x.get("date", ""), x.get("lesson", 0)))
def _fetch_schedule_browser():
log.info("Falling back to browser...")
with sync_playwright() as p:
browser = p.chromium.launch(headless=True, args=["--no-sandbox", "--disable-blink-features=AutomationControlled"])
try:
if SESSION_FILE.exists():
context = browser.new_context(
storage_state=str(SESSION_FILE),
user_agent="Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
)
set_status(message="Updating via browser...")
else:
set_status(message="No session available.")
browser.close()
return None
schedule_data = []
seen = set()
page = context.pages[0] if context.pages else context.new_page()
def handle_response(response):
try:
if "/api/v2/schedule/operations/get-month" not in response.url:
return
if response.status != 200:
return
data = response.json()
if isinstance(data, list):
for item in data:
key = (item.get("date"), item.get("lesson"))
if key not in seen:
seen.add(key)
schedule_data.append(item)
except Exception:
pass
page.on("response", handle_response)
page.goto(SCHEDULE_URL)
page.wait_for_load_state("networkidle")
page.wait_for_timeout(5000)
finally:
browser.close()
if not schedule_data:
return None
return sorted(schedule_data, key=lambda x: (x.get("date", ""), x.get("lesson", 0)))
def _web_relogin():
"""Renew the journal session using protected perms-600 credentials file."""
if not CREDENTIALS_FILE.exists():
log.warning("No credentials file; relogin skipped.")
return False
try:
with open(CREDENTIALS_FILE, "r", encoding="utf-8") as f:
lines = f.read().splitlines()
if len(lines) < 2:
log.warning("Credentials file malformed.")
return False
username, password = lines[0].strip(), lines[1].strip()
if not username or not password:
return False
except Exception as e:
log.error("Failed to read credentials: %s", e)
return False
try:
log.info("Relogin: opening login page...")
with sync_playwright() as p:
browser = p.chromium.launch(
headless=True,
args=["--no-sandbox", "--disable-blink-features=AutomationControlled"],
)
context = browser.new_context()
page = context.new_page()
page.goto(LOGIN_URL)
page.wait_for_load_state("networkidle")
page.wait_for_timeout(2000)
try:
page.fill('input[name="username"]', username)
page.fill('input[name="password"]', password)
page.wait_for_timeout(500)
page.click('button[type="submit"], input[type="submit"]')
page.wait_for_load_state("networkidle")
page.wait_for_timeout(4000)
except Exception as e:
log.warning("Relogin form step error: %s", e)
if "login" not in page.url:
context.storage_state(path=str(SESSION_FILE))
log.info("Relogin successful. Session renewed and saved.")
return True
else:
log.warning("Relogin failed: still on login page.")
return False
except Exception as e:
log.error("Relogin error: %s", e)
return False
def _retry_after_relogin():
log_event("warning", "Сессия журнала устарела — пытаюсь войти заново (автоматически)...")
if not _web_relogin():
log_event("error", "Не удалось заново войти в журнал. Проверь аккаунт.")
return None
log_event("info", "Вход в журнал выполнен заново. Повторяю загрузку расписания...")
result = _fetch_schedule_direct()
if result:
merged = _merge_with_previous(result)
safe_json_save(SCHEDULE_FILE, merged)
set_status(
last_update=datetime.now().strftime("%H:%M:%S"),
message=f"Готово: {len(merged)} занятий (переподключение).",
)
log.info("Fetch complete (relogin+http): %d lessons.", len(merged))
log_event("ok", f"Расписание обновлено после повторного входа: {len(merged)} занятий (данные журнала: {len(result)}).")
return merged
result = _fetch_schedule_browser()
if result:
merged = _merge_with_previous(result)
safe_json_save(SCHEDULE_FILE, merged)
set_status(
last_update=datetime.now().strftime("%H:%M:%S"),
message=f"Готово: {len(merged)} занятий (браузер).",
)
log.info("Fetch complete (relogin+browser): %d lessons.", len(merged))
log_event("ok", f"Расписание обновлено после повторного входа (браузер): {len(merged)} занятий (данные журнала: {len(result)}).")
return merged
log_event("error", "После повторного входа расписание так и не получилось загрузить.")
return None
def _get_schedule_inner():
set_status(message="Получаю расписание из журнала...")
log_event("info", "Начинаю обновление расписания...")
result = _fetch_schedule_direct()
if result:
merged = _merge_with_previous(result)
safe_json_save(SCHEDULE_FILE, merged)
set_status(
last_update=datetime.now().strftime("%H:%M:%S"),
message=f"Готово: {len(merged)} занятий.",
)
log.info("Fetch complete (HTTP): %d lessons.", len(merged))
log_event("ok", f"Журнал ответил (напрямую): {len(result)} занятий. Показано: {len(merged)}.")
return merged
log.info("Direct fetch failed, trying browser...")
log_event("warning", "Прямое соединение с журналом не сработало — открываю журнал в браузере...")
if not SESSION_FILE.exists():
log.info("No session file; going straight to relogin...")
return _retry_after_relogin()
result = _fetch_schedule_browser()
if result:
merged = _merge_with_previous(result)
safe_json_save(SCHEDULE_FILE, merged)
set_status(
last_update=datetime.now().strftime("%H:%M:%S"),
message=f"Готово: {len(merged)} занятий (браузер).",
)
log.info("Fetch complete (browser): %d lessons.", len(merged))
log_event("ok", f"Журнал открылся в браузере: {len(result)} занятий. Показано: {len(merged)}.")
return merged
log_event("error", "Браузер тоже не смог получить расписание из журнала.")
log.warning("Session likely expired; attempting relogin...")
return _retry_after_relogin()
def run_update():
log.info("Update thread started.")
try:
set_status(running=True, last_error="")
for attempt in range(1, 4):
try:
log.info("Attempt %d/3", attempt)
get_schedule()
break
except Exception as e:
log.warning("Attempt %d failed: %s", attempt, e, exc_info=True)
log_event("error", f"Попытка {attempt} не удалась: {e}")
if attempt < 3:
set_status(message=f"Попытка {attempt} не удалась, повтор...")
except Exception as e:
set_status(message=f"Ошибка: {e}", last_error=str(e))
log.error("All update attempts failed.", exc_info=True)
log_event("error", f"Обновление окончательно не удалось: {e}")
finally:
set_status(running=False)
log.info("Update thread finished.")
@app.route("/")
def index():
try:
return Response(INDEX_HTML, mimetype="text/html")
except Exception as e:
log.error("Failed to serve index: %s", e)
return "Internal Server Error", 500
@app.route("/api/schedule")
def api_schedule():
try:
data = safe_json_load(SCHEDULE_FILE)
return jsonify(sorted(data, key=lambda x: (x.get("date", ""), x.get("lesson", 0))))
except Exception as e:
log.error("API /schedule error: %s", e)
return jsonify([])
@app.route("/api/update", methods=["POST"])
def api_update():
try:
with status_lock:
if fetch_status["running"]:
return jsonify({"ok": False, "message": "Already updating"})
thread = threading.Thread(target=run_update, daemon=True)
thread.start()
log.info("Update triggered by user.")
return jsonify({"ok": True, "message": "Update started"})
except Exception as e:
log.error("API /update error: %s", e)
return jsonify({"ok": False, "message": str(e)})
@app.route("/api/status")
def api_status():
try:
with status_lock:
return jsonify(dict(fetch_status))
except Exception as e:
return jsonify({"running": False, "message": "", "last_error": ""})
@app.route("/api/logs")
def api_logs():
try:
with activity_lock:
return jsonify(list(activity_log[-30:]))
except Exception:
return jsonify([])
@app.errorhandler(404)
def not_found(e):
return jsonify({"error": "Not found"}), 404
@app.errorhandler(500)
def server_error(e):
log.error("500 error: %s", e)
return jsonify({"error": "Internal server error"}), 500
INDEX_HTML = r"""<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>TRIRON — Расписание</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<style>
:root{
--bg:#edf0f6;--surface:#ffffff;--surface-2:#eef1f7;--surface-3:#e2e7f0;
--border:rgba(20,26,45,.09);--border-strong:rgba(20,26,45,.15);
--text:#182031;--text-2:#515a70;--text-3:#8b93a8;
--accent:#4f46e5;--accent-dark:#4338ca;--accent-soft:rgba(79,70,229,.09);--accent-softer:rgba(79,70,229,.05);
--cyan:#0891b2;--cyan-soft:rgba(8,145,178,.08);
--success:#15803d;--success-soft:rgba(21,128,61,.09);
--warn:#b45309;--warn-soft:rgba(180,83,9,.1);
--danger:#dc2626;--danger-soft:rgba(220,38,38,.08);
--shadow-sm:0 1px 2px rgba(20,26,45,.05),0 1px 3px rgba(20,26,45,.07);
--shadow-md:0 2px 4px rgba(20,26,45,.06),0 12px 30px rgba(20,26,45,.08);
--shadow-lg:0 4px 10px rgba(20,26,45,.07),0 20px 48px rgba(20,26,45,.12);
--sidebar-w:242px;--radius:14px;--radius-sm:9px;--radius-xs:6px;
--hero-glow:rgba(79,70,229,.12)
}
[data-theme="dark"]{
--bg:#0e1118;--surface:#171c29;--surface-2:#1f2636;--surface-3:#2a3246;
--border:rgba(255,255,255,.07);--border-strong:rgba(255,255,255,.15);
--text:#eceef4;--text-2:#a8b0c3;--text-3:#6f7890;
--accent:#8a93f9;--accent-dark:#a5adfb;--accent-soft:rgba(138,147,249,.14);--accent-softer:rgba(138,147,249,.07);
--cyan:#22d3ee;--cyan-soft:rgba(34,211,238,.1);
--success:#4ade80;--success-soft:rgba(74,222,128,.14);
--warn:#fbbf24;--warn-soft:rgba(251,191,36,.14);
--danger:#f87171;--danger-soft:rgba(248,113,113,.12);
--shadow-sm:0 1px 2px rgba(0,0,0,.3),0 1px 3px rgba(0,0,0,.38);
--shadow-md:0 2px 4px rgba(0,0,0,.4),0 12px 30px rgba(0,0,0,.45);
--shadow-lg:0 4px 10px rgba(0,0,0,.45),0 20px 48px rgba(0,0,0,.55);
--hero-glow:rgba(99,102,241,.18);
--grid-line:rgba(255,255,255,.035)
}
*{box-sizing:border-box;margin:0;padding:0}
html{scroll-behavior:smooth}
body{font-family:'Inter',system-ui,-apple-system,'Segoe UI',sans-serif;color:var(--text);font-size:15px;line-height:1.5;overflow-x:hidden;-webkit-font-smoothing:antialiased;min-height:100vh}
[data-theme="dark"] body{background:radial-gradient(1100px 520px at 90% -8%,rgba(99,102,241,.15),transparent 60%),radial-gradient(760px 420px at -6% 28%,rgba(34,211,238,.05),transparent 55%),var(--bg)}
[data-theme="light"] body{background:radial-gradient(1100px 520px at 90% -8%,rgba(99,102,241,.06),transparent 60%),radial-gradient(760px 420px at -6% 28%,rgba(8,145,178,.04),transparent 55%),var(--bg)}
a{color:var(--accent);text-decoration:none}
a:hover{text-decoration:underline}
button{font-family:inherit;cursor:pointer;color:inherit}
input,select,textarea{font-family:inherit;color:inherit}
:focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:5px}
/* SIDEBAR */
.sidebar{position:fixed;top:0;left:0;bottom:0;width:var(--sidebar-w);background:linear-gradient(180deg,var(--surface),var(--surface-2));border-right:1px solid var(--border);display:flex;flex-direction:column;z-index:100}
.sidebar-brand{padding:22px 20px 17px;border-bottom:1px solid var(--border)}
.sidebar-brand h1{font-size:18px;font-weight:800;letter-spacing:.14em;color:var(--text)}
.sidebar-brand h1 b{color:var(--accent)}
.sidebar-brand .sb-dot{display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--accent);margin-left:2px;vertical-align:middle;animation:blink 2.2s ease-in-out infinite}
.sidebar-brand span{display:block;font-size:11px;color:var(--text-3);margin-top:3px;letter-spacing:.03em}
.sidebar-nav{flex:1;padding:12px 10px;overflow-y:auto}
.nav-item{display:flex;align-items:center;gap:11px;width:100%;padding:10px 12px;border:none;background:none;border-radius:var(--radius-sm);color:var(--text-2);font-size:13.5px;font-weight:500;text-align:left;transition:background .15s,color .15s}
.nav-item:hover{background:var(--accent-softer);color:var(--text)}
.nav-item.active{background:var(--accent-soft);color:var(--accent);font-weight:600}
.nav-item svg{width:17px;height:17px;flex-shrink:0;opacity:.75}
.nav-item.active svg{opacity:1}
.sidebar-foot{padding:12px 10px;border-top:1px solid var(--border)}
.theme-toggle{display:flex;align-items:center;gap:11px;width:100%;padding:10px 12px;border:none;background:none;border-radius:var(--radius-sm);color:var(--text-2);font-size:13px;font-weight:500;transition:background .15s,color .15s}
.theme-toggle:hover{background:var(--accent-softer);color:var(--text)}
.theme-toggle svg{width:16px;height:16px;opacity:.75}
.sidebar-src{padding:5px 12px 2px;font-size:10.5px;color:var(--text-3)}
/* MOBILE NAV */
.mobile-nav{display:none;position:fixed;bottom:0;left:0;right:0;z-index:100;background:var(--surface);border-top:1px solid var(--border);padding:4px 0 calc(4px + env(safe-area-inset-bottom,0px))}
.mobile-nav-inner{display:flex}
.mob-nav{flex:1;display:flex;flex-direction:column;align-items:center;gap:2px;padding:8px 4px;border:none;background:none;color:var(--text-3);font-size:10.5px;font-weight:500}
.mob-nav.active{color:var(--accent)}
.mob-nav svg{width:19px;height:19px}
/* MAIN */
.main{margin-left:var(--sidebar-w);min-height:100vh;padding:28px 36px 90px}
.main-inner{max-width:900px;margin:0 auto}
/* HEADER */
.header{margin-bottom:20px}
.header-row{display:flex;align-items:flex-start;justify-content:space-between;gap:16px}
.header-greeting{font-size:22px;font-weight:800;letter-spacing:-.02em;color:var(--text)}
.header-status{font-size:13px;color:var(--text-3);margin-top:3px;font-weight:500}
.header-actions{display:flex;flex-direction:column;align-items:flex-end;gap:5px}
.btn-refresh{display:inline-flex;align-items:center;gap:7px;padding:8px 15px;background:var(--surface);border:1px solid var(--border-strong);border-radius:var(--radius-sm);font-size:12.5px;font-weight:600;color:var(--text-2);transition:all .15s;box-shadow:var(--shadow-sm)}
.btn-refresh:hover{border-color:var(--accent);color:var(--accent)}
.btn-refresh:disabled{opacity:.7;cursor:default}
.btn-refresh.spin svg{animation:spin .7s linear infinite}
.btn-refresh svg{width:14px;height:14px}
@keyframes spin{to{transform:rotate(360deg)}}
.last-update{font-size:11px;color:var(--text-3);font-variant-numeric:tabular-nums}
/* SEARCH */
.search{position:relative;margin-top:14px}
.search input{width:100%;padding:10px 36px 10px 36px;border:1px solid var(--border-strong);background:var(--surface);border-radius:var(--radius-sm);font-size:13.5px;color:var(--text);transition:border .15s,box-shadow .15s;box-shadow:var(--shadow-sm)}
.search input::placeholder{color:var(--text-3)}
.search input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-softer)}
.search .s-ic{position:absolute;left:11px;top:50%;transform:translateY(-50%);width:15px;height:15px;color:var(--text-3);pointer-events:none}
.search .s-kbd{position:absolute;right:10px;top:50%;transform:translateY(-50%);font-size:10px;color:var(--text-3);border:1px solid var(--border);border-radius:4px;padding:1px 6px;background:var(--surface-2);pointer-events:none}
.search-results{position:absolute;top:calc(100% + 6px);left:0;right:0;background:var(--surface);border:1px solid var(--border-strong);border-radius:var(--radius-sm);box-shadow:var(--shadow-lg);max-height:340px;overflow-y:auto;z-index:60;display:none}
.search-results.show{display:block}
.sr-hint{padding:9px 12px;font-size:11.5px;color:var(--text-3)}
.sr-item{display:block;width:100%;text-align:left;padding:9px 12px;border:none;border-bottom:1px solid var(--border);background:none;cursor:pointer;transition:background .12s}
.sr-item:last-child{border-bottom:none}
.sr-item:hover{background:var(--accent-softer)}
.sr-item b{display:block;font-size:13px;font-weight:600;color:var(--text)}
.sr-item mark{background:var(--warn-soft);color:var(--warn);border-radius:3px;padding:0 2px}
.sr-meta{display:flex;flex-wrap:wrap;gap:4px 14px;margin-top:2px;font-size:11.5px;color:var(--text-3)}
.sr-meta span{display:inline-flex;align-items:center;gap:4px}
.sr-meta svg{width:11px;height:11px;opacity:.6}
/* DAY STRIP */
.strip{margin:0 0 16px}
.strip-label{display:flex;align-items:baseline;justify-content:space-between;margin-bottom:8px}
.strip-label h2{font-size:12.5px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--text-3)}
.strip-label .strip-range{font-size:12px;color:var(--text-3);font-variant-numeric:tabular-nums}
.strip-grid{display:grid;grid-template-columns:repeat(7,1fr);gap:6px}
.strip-day{padding:9px 4px 8px;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-sm);text-align:center;transition:all .15s;box-shadow:var(--shadow-sm)}
.strip-day:hover{border-color:var(--accent);transform:translateY(-1px)}
.strip-day .sd-name{display:block;font-size:10.5px;text-transform:uppercase;letter-spacing:.05em;font-weight:600;color:var(--text-3)}
.strip-day .sd-num{display:block;font-size:17px;font-weight:700;color:var(--text);margin:1px 0;font-variant-numeric:tabular-nums}
.strip-day .sd-cnt{display:block;font-size:10px;font-weight:500;color:var(--text-3);font-variant-numeric:tabular-nums}
.strip-day.wknd{background:transparent;border-style:dashed;opacity:.8;box-shadow:none}
.strip-day.today{border-color:var(--border-strong);background:linear-gradient(180deg,var(--accent-softer),transparent);position:relative}
.strip-day.today .sd-name{color:var(--accent);font-weight:700}
.strip-day.today .sd-num{color:var(--accent-dark)}
[data-theme="dark"] .strip-day.today .sd-num{color:var(--accent)}
.strip-day.today .sd-cnt{color:var(--accent)}
.strip-day.today::after{content:'';position:absolute;left:12%;right:12%;bottom:0;height:2px;background:var(--accent);border-radius:2px 2px 0 0}
.strip-day.selected{background:var(--accent);border-color:var(--accent);color:#fff;box-shadow:0 4px 14px var(--accent-soft)}
.strip-day.selected .sd-name,.strip-day.selected .sd-num,.strip-day.selected .sd-cnt{color:#fff}
.strip-day.selected .sd-cnt{color:rgba(255,255,255,.85)}
.strip-day.selected::after{background:transparent}
/* MINI STATS */
.minim{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin:0 0 16px}
.mini{background:linear-gradient(180deg,var(--surface),var(--surface-2));border:1px solid var(--border);border-radius:var(--radius-sm);padding:11px 14px;box-shadow:var(--shadow-sm)}
.mini b{display:block;font-size:21px;font-weight:800;color:var(--text);font-variant-numeric:tabular-nums;line-height:1.1}
.mini b i{font-style:normal;color:var(--accent)}
.mini span{font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:var(--text-3)}
/* DAY HEAD */
.day-head{margin:4px 0 14px}
.day-head .day-title{font-size:13px;font-weight:600;color:var(--text-2)}
.day-head .day-title b{color:var(--text);font-size:15px;font-weight:800}
.day-head .day-count{font-size:12.5px;color:var(--text-3);margin-top:2px}
/* HERO / STATUS */
.status{position:relative;isolation:isolate;margin-bottom:16px;padding:22px 24px;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);box-shadow:var(--shadow-md);overflow:hidden}
.status::before{content:'';position:absolute;inset:0;z-index:-1;background:radial-gradient(640px 320px at 92% -20%,var(--hero-glow),transparent 62%);pointer-events:none}
.status::after{content:'';position:absolute;inset:0;z-index:-1;background-image:linear-gradient(var(--grid-line) 1px,transparent 1px),linear-gradient(90deg,var(--grid-line) 1px,transparent 1px);background-size:112px 112px;opacity:.55;-webkit-mask-image:radial-gradient(circle at 90% 0,black,transparent 58%);mask-image:radial-gradient(circle at 90% 0,black,transparent 58%);pointer-events:none}
.status::marker{color:transparent}
.status.st-live{background:linear-gradient(135deg,var(--accent-softer),var(--surface) 52%);border-color:var(--border-strong);border-left:3px solid var(--accent);box-shadow:var(--shadow-md)}
.status.st-live::before{background:radial-gradient(640px 320px at 92% -20%,rgba(99,102,241,.2),transparent 62%)}
[data-theme="dark"] .status.st-live::before{background:radial-gradient(640px 320px at 92% -20%,rgba(99,102,241,.3),transparent 62%)}
.status.st-next{background:linear-gradient(135deg,var(--warn-soft),var(--surface) 50%);border-left:3px solid var(--warn)}
.status.st-done{background:linear-gradient(135deg,var(--success-soft),var(--surface) 55%);border-left:3px solid var(--success)}
.status.st-weekend{border-left:3px solid var(--text-3)}
.status.st-quiet{border-left:3px solid var(--accent)}
.st-top{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap}
.st-badge{display:inline-flex;align-items:center;gap:6px;font-size:11px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;padding:3px 10px;border-radius:999px}
.st-badge i{width:6px;height:6px;border-radius:50%;background:currentColor}
.st-badge.b-live{color:var(--accent);background:var(--accent-soft)}
.st-badge.b-live i{animation:blink 1.8s ease-in-out infinite}
.st-badge.b-next{color:var(--warn);background:var(--warn-soft)}
.st-badge.b-done{color:var(--success);background:var(--success-soft)}
.st-badge.b-quiet{color:var(--text-3);background:var(--surface-2)}
@keyframes blink{0%,100%{opacity:1}50%{opacity:.25}}
.st-day{font-size:11.5px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:var(--text-3);margin-bottom:6px}
.st-day b{color:var(--accent);font-weight:800}
.st-time{font-size:24px;font-weight:800;letter-spacing:-.02em;color:var(--text);font-variant-numeric:tabular-nums}
.st-subj{font-size:17px;font-weight:700;color:var(--text);margin-top:3px;line-height:1.35}
.st-meta{display:flex;flex-wrap:wrap;gap:6px 18px;margin-top:9px;font-size:13px;color:var(--text-2)}
.st-meta span{display:inline-flex;align-items:center;gap:6px}
.st-meta svg{width:13px;height:13px;opacity:.55}
.st-countdown{margin-top:13px;font-size:13px;font-weight:600;color:var(--accent);font-variant-numeric:tabular-nums}
.st-progress{margin-top:9px;height:5px;background:var(--surface-3);border-radius:999px;overflow:hidden}
.st-progress div{height:100%;background:linear-gradient(90deg,var(--accent),var(--cyan));border-radius:999px;transition:width .8s ease}
.st-nextline{margin-top:14px;padding-top:11px;border-top:1px dashed var(--border-strong);font-size:12.5px;color:var(--text-2);display:flex;align-items:center;gap:8px}
.st-nextline b{color:var(--text);font-weight:700}
.st-nextline .tl-badge{margin-left:auto}
.st-msg{font-size:15px;font-weight:700;color:var(--text)}
.st-sub{font-size:12.5px;color:var(--text-3);margin-top:3px}
.st-art{position:absolute;right:16px;bottom:10px;width:110px;height:110px;color:var(--accent);opacity:.14;pointer-events:none;z-index:-1}
/* DAY PROGRESS */
.day-progress{display:flex;align-items:center;gap:12px;margin:0 0 18px;padding:0 2px}
.dp-bar{flex:1;height:5px;background:var(--surface-3);border-radius:999px;overflow:hidden}
.dp-bar div{height:100%;background:linear-gradient(90deg,var(--success),var(--accent));border-radius:999px;transition:width .7s ease}
.dp-label{font-size:11.5px;color:var(--text-3);white-space:nowrap;font-variant-numeric:tabular-nums}
/* TIMELINE */
.timeline{display:flex;flex-direction:column}
.header-tl{display:flex;align-items:baseline;justify-content:space-between;margin:0 0 10px}
.header-tl span{font-size:12.5px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--text-3)}
.header-tl small{font-size:11.5px;color:var(--text-3)}
.tl{display:grid;grid-template-columns:56px 14px 1fr;gap:6px 8px;margin-bottom:10px;align-items:stretch}
.tl-time{padding-top:14px;text-align:right;font-size:12.5px;font-weight:700;color:var(--text);font-variant-numeric:tabular-nums}
.tl-line{position:relative}
.tl-line::before{content:'';position:absolute;left:6px;top:0;bottom:-10px;width:1px;background:var(--surface-3)}
.tl:last-child .tl-line::before{bottom:0}
.tl-line .dot{position:absolute;left:2px;top:14px;width:9px;height:9px;border-radius:50%;background:var(--surface-3);border:2px solid var(--surface);box-shadow:0 0 0 1px var(--border-strong)}
.tl-card{padding:12px 15px;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-sm);box-shadow:var(--shadow-sm);cursor:pointer;transition:all .15s;position:relative}
.tl-card:hover{border-color:var(--accent);box-shadow:var(--shadow-md);transform:translateY(-1px)}
.tl-top{display:flex;align-items:center;gap:8px;margin-bottom:4px}
.tl-para{display:inline-flex;align-items:center;gap:7px;font-size:10px;font-weight:700;color:var(--text-3);text-transform:uppercase;letter-spacing:.05em;font-variant-numeric:tabular-nums}
.mk{display:inline-flex;align-items:center;justify-content:center;width:16px;height:16px;border-radius:50%;font-size:9.5px;line-height:1;color:var(--text-3);border:1px solid var(--border-strong);background:var(--surface-2)}
.mk.done{color:var(--success);border-color:var(--success);background:var(--success-soft)}
.mk.cur{color:var(--accent);border-color:var(--accent);background:var(--accent-soft)}
.mk.up{color:var(--text-3)}
.tl-range{margin-left:auto;font-size:11px;color:var(--text-3);font-variant-numeric:tabular-nums}
.tl-subj{font-size:13.5px;font-weight:600;color:var(--text);line-height:1.35}
.tl-teacher{font-size:12px;color:var(--text-2);margin-top:2px}
.tl-room{display:inline-block;margin-top:5px;font-size:11px;font-weight:600;color:var(--accent);background:var(--accent-softer);border:1px solid var(--accent-soft);padding:1px 9px;border-radius:999px}
.tl-badge{font-size:10px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;padding:2px 9px;border-radius:999px;flex-shrink:0}
.tl-badge.tl-live{color:var(--accent);background:var(--accent-soft)}
.tl-badge.tl-next{color:var(--warn);background:var(--warn-soft)}
.tl.current .tl-line .dot{background:var(--accent);border-color:var(--surface);box-shadow:0 0 0 1px var(--accent),0 0 0 5px var(--accent-softer)}
.tl.current .tl-line::before{background:var(--accent)}
.tl.current .tl-card{border-color:var(--accent);border-left:3px solid var(--accent);background:linear-gradient(135deg,var(--accent-softer),var(--surface) 60%);box-shadow:var(--shadow-md)}
.tl.current .tl-card:hover{border-color:var(--accent)}
.tl.current .tl-subj{color:var(--accent-dark)}
[data-theme="dark"] .tl.current .tl-subj{color:var(--accent)}
.tl.past .tl-card{opacity:.55}
.tl.past .tl-card:hover{opacity:.95}
.tl.upcoming .tl-line .dot{background:var(--surface)}
.tl-time.past{color:var(--text-3)}
/* EMPTY */
.empty{padding:30px 20px;text-align:center;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);box-shadow:var(--shadow-sm)}
.empty .e-t{font-size:14px;font-weight:700;color:var(--text)}
.empty .e-s{font-size:12.5px;color:var(--text-3);margin-top:3px}
/* TOMORROW */
.tomorrow{margin-top:24px;position:relative;background:linear-gradient(180deg,var(--surface),var(--surface-2));border:1px solid var(--border);border-left:3px solid var(--accent);border-radius:var(--radius);box-shadow:var(--shadow-sm);overflow:hidden}
.tomorrow .t-head{display:flex;align-items:baseline;justify-content:space-between;gap:10px;padding:13px 16px 9px}
.tomorrow .t-head span{font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:var(--accent)}
.tomorrow .t-head small{font-size:12px;color:var(--text-3);font-variant-numeric:tabular-nums}
.tomorrow .t-rowc{padding:0}
.tom-row{display:flex;align-items:center;gap:12px;padding:8px 16px;border-top:1px solid var(--border);cursor:pointer;transition:background .12s}
.tom-row:first-child{border-top:none}
.tom-row:hover{background:var(--accent-softer)}
.tom-time{font-size:12px;font-weight:700;color:var(--text);font-variant-numeric:tabular-nums;width:38px;flex-shrink:0}
.tom-subj{font-size:13px;font-weight:500;color:var(--text);flex:1;min-width:0}
.tom-note{font-size:12.5px;color:var(--text-3);padding:12px 16px}
.tom-btn{display:block;width:100%;margin-top:2px;padding:10px 16px;border:none;border-top:1px solid var(--border);background:var(--accent-soft);color:var(--accent);font-size:12.5px;font-weight:600;text-align:center;transition:background .12s}
.tom-btn:hover{background:var(--accent-softer)}
.tom-btn:active{transform:translateY(1px)}
/* WEEK PAGE */
.week-days{display:flex;flex-direction:column;gap:10px;margin-bottom:18px}
.wd{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-sm);box-shadow:var(--shadow-sm);overflow:hidden}
.wd.today{border-color:var(--accent)}
.wd-head{display:flex;align-items:baseline;gap:8px;padding:9px 14px;background:var(--surface-2);border-bottom:1px solid var(--border)}
.wd-head b{font-size:13px;font-weight:700;color:var(--text)}
.wd-head span{font-size:11.5px;color:var(--text-3)}
.wd-head .wd-cnt{margin-left:auto;font-size:11px;font-weight:600;color:var(--text-3)}
.wd.today .wd-head{background:var(--accent-softer)}
.wd.today .wd-head b{color:var(--accent)}
.wd-empty{padding:10px 14px;font-size:12px;color:var(--text-3)}
/* ERROR */
.error-state{padding:16px 20px;margin-bottom:16px;background:var(--danger-soft);border:1px solid var(--danger);border-radius:var(--radius);box-shadow:var(--shadow-sm)}
.error-state-title{font-size:13.5px;font-weight:700;color:var(--danger)}
.error-state-sub{font-size:12.5px;color:var(--text-2);margin:4px 0 10px}
.btn-retry{padding:6px 14px;background:var(--danger);color:#fff;border:none;border-radius:var(--radius-xs);font-size:12.5px;font-weight:600}
.btn-retry:hover{filter:brightness(1.06)}
/* FOOTER */
.footer{margin-top:32px;padding-top:16px;border-top:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;font-size:11px;color:var(--text-3);flex-wrap:wrap;gap:6px}
.footer kbd{background:var(--surface-2);border:1px solid var(--border);border-radius:4px;padding:0 5px;font-size:10px;font-family:inherit}
/* LOG PANEL */
.log-panel{margin-top:14px;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-sm);box-shadow:var(--shadow-sm);overflow:hidden}
.log-head{display:flex;align-items:center;gap:8px;width:100%;padding:11px 14px;border:none;background:none;color:var(--text-2);font-size:12.5px;font-weight:600;text-align:left}
.log-head:hover{background:var(--accent-softer);color:var(--text)}
.log-head svg{width:14px;height:14px;opacity:.7}
.log-badge{margin-left:4px;background:var(--accent-soft);color:var(--accent);border-radius:999px;padding:0 8px;font-size:10px;font-weight:700}
.log-toggle{margin-left:auto;color:var(--text-3);font-size:10px;transition:transform .2s}
.log-panel.open .log-toggle{transform:rotate(180deg)}
.log-list{display:none;max-height:260px;overflow-y:auto;border-top:1px solid var(--border);padding:6px 0}
.log-panel.open .log-list{display:block}
.log-item{display:flex;gap:9px;padding:5px 14px;font-size:12px;line-height:1.45;color:var(--text-2)}
.log-item .log-time{color:var(--text-3);flex-shrink:0;font-size:10.5px;padding-top:1px}
.log-item .log-dot{flex-shrink:0;width:6px;height:6px;border-radius:50%;margin-top:4px}
.log-item.ok .log-dot{background:var(--success)}
.log-item.warning .log-dot{background:var(--warn)}
.log-item.error .log-dot{background:var(--danger)}
.log-item.info .log-dot{background:var(--accent)}
.log-empty{padding:12px 14px;font-size:12px;color:var(--text-3);text-align:center}
/* ANIM */
@keyframes fadeUp{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}
.anim{animation:fadeUp .32s ease-out both}
@keyframes fadeIn{from{opacity:0}to{opacity:1}}
.fadein{animation:fadeIn .25s ease-out both}
/* MODAL */
.modal-backdrop{position:fixed;inset:0;background:rgba(10,12,20,.55);z-index:200;opacity:0;pointer-events:none;transition:opacity .2s}
.modal-backdrop.show{opacity:1;pointer-events:auto}
.modal{position:fixed;top:50%;left:50%;transform:translate(-50%,-50%) scale(.97);z-index:201;background:var(--surface);border:1px solid var(--border-strong);border-radius:var(--radius);padding:24px;width:92%;max-width:430px;box-shadow:var(--shadow-lg);opacity:0;pointer-events:none;transition:all .2s ease-out}
.modal.show{opacity:1;transform:translate(-50%,-50%) scale(1);pointer-events:auto}
.modal-close{position:absolute;top:10px;right:10px;width:28px;height:28px;border-radius:50%;border:none;background:var(--surface-2);color:var(--text-2);font-size:15px;display:flex;align-items:center;justify-content:center;transition:all .15s}
.modal-close:hover{background:var(--surface-3);color:var(--text)}
.modal-title{font-size:16px;font-weight:700;color:var(--text);padding-right:24px}
.modal-sub{font-size:12px;color:var(--text-3);margin-top:2px}
.modal-body{margin-top:14px}
.modal-row{display:flex;align-items:center;gap:10px;padding:8px 0;border-bottom:1px solid var(--border);font-size:13px}
.modal-row:last-child{border-bottom:none}
.modal-row svg{width:15px;height:15px;color:var(--text-3);flex-shrink:0}
.modal-row span{color:var(--text-2)}
.modal-row strong{color:var(--text);font-weight:600;margin-left:auto;text-align:right}
/* TOAST */
.toast{position:fixed;bottom:22px;right:22px;padding:11px 18px;background:var(--surface);border:1px solid var(--border-strong);border-radius:var(--radius-sm);font-size:13px;color:var(--text);box-shadow:var(--shadow-lg);z-index:1000;opacity:0;transform:translateY(12px);pointer-events:none;transition:all .3s}
.toast.show{opacity:1;transform:none}
.toast.success{border-color:var(--success)}
.toast.error{border-color:var(--danger)}
/* SKELETON */
.skeleton{position:relative;overflow:hidden;background:var(--surface-2);border-radius:var(--radius-sm)}
.skeleton::after{content:'';position:absolute;inset:0;background:linear-gradient(90deg,transparent,rgba(255,255,255,.06),transparent);animation:shimmer 2s infinite}
@keyframes shimmer{to{transform:translateX(100%)}}
.sk-status{height:104px;margin-bottom:16px;border-radius:var(--radius)}
.sk-tl{height:52px;margin-bottom:10px}
/* RESPONSIVE */
@media(max-width:820px){
.main{margin-left:0;padding:18px 14px 84px}
.mobile-nav{display:block}
.sidebar{display:none}
.header-row{align-items:flex-start}
.header-greeting{font-size:18px}
.strip-grid{gap:4px}
.strip-day{padding:7px 2px 6px}
.strip-day .sd-num{font-size:15px}
.tl{grid-template-columns:42px 12px 1fr}
.tl-time{font-size:11.5px}
.st-time{font-size:20px}
.st-subj{font-size:15px}
.minim{grid-template-columns:repeat(2,1fr);gap:8px}
.st-art{right:6px;bottom:0;width:84px;height:84px}
.search-results{max-height:280px}
}
@media(prefers-reduced-motion:reduce){
*,*::before,*::after{animation-duration:.01ms!important;animation-iteration-count:1!important;transition-duration:.01ms!important}
html{scroll-behavior:auto}
}
</style>
</head>
<body>
<nav class="sidebar" id="sidebar">
<div class="sidebar-brand">
<h1>TR<b>I</b>RON<span class="sb-dot"></span></h1>
<span>Персональное расписание</span>
</div>
<nav class="sidebar-nav">
<button class="nav-item active" data-page="today" onclick="switchPage('today')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
Расписание
</button>
<button class="nav-item" data-page="week" onclick="switchPage('week')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>
Неделя
</button>
</nav>
<div class="sidebar-foot">
<button class="theme-toggle" id="themeBtn" onclick="toggleTheme()" aria-label="Сменить тему">
<svg id="themeIcon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
<span id="themeText">Тёмная тема</span>
</button>
<div class="sidebar-src">journal.top-academy.ru</div>
</div>
</nav>
<nav class="mobile-nav">
<div class="mobile-nav-inner">
<button class="mob-nav active" data-page="today" onclick="switchPage('today')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
Сегодня
</button>
<button class="mob-nav" data-page="week" onclick="switchPage('week')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>
Неделя
</button>
<button class="mob-nav" id="mobTheme" onclick="toggleTheme()">
<svg id="mobThemeIcon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
Тема
</button>
</div>
</nav>
<main class="main">
<div class="main-inner">
<header class="header">
<div class="header-row">
<div>
<div class="header-greeting" id="greeting"></div>
<div class="header-status" id="headerStatus"></div>
</div>
<div class="header-actions">
<button class="btn-refresh" id="refreshBtn" onclick="doUpdate()" aria-label="Обновить расписание" title="Обновить расписание (R)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
<span id="refreshText">Обновить</span>
</button>
<div class="last-update" id="lastUpdate"></div>
</div>
</div>
<div class="search">
<svg class="s-ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input id="searchInput" type="text" placeholder="Поиск: предмет, преподаватель, кабинет" autocomplete="off">
<span class="s-kbd" id="searchKbd">/</span>
<div class="search-results" id="searchResults"></div>
</div>
</header>
<div id="errorBox"></div>
<div id="pageContent"></div>
<footer class="footer">
<span>TRIRON · источник TOP Academy</span>
<div><kbd>/</kbd> поиск · <kbd>R</kbd> обновить · <kbd>T</kbd> сегодня · <kbd>Esc</kbd> закрыть</div>
</footer>
<div class="log-panel" id="logPanel">
<button class="log-head" onclick="toggleLogs()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
Журнал обновлений
<span class="log-badge" id="logBadge">–</span>
<span class="log-toggle">▼</span>
</button>
<div class="log-list" id="logList"><div class="log-empty">Загружаю...</div></div>
</div>
</div>
</main>
<div class="toast" id="toast"></div>
<div class="modal-backdrop" id="modalBackdrop" onclick="closeModal()"></div>
<div class="modal" id="modal">
<button class="modal-close" onclick="closeModal()">&times;</button>
<div class="modal-title" id="modalTitle"></div>
<div class="modal-sub" id="modalSub"></div>
<div class="modal-body" id="modalBody"></div>
</div>
<script>
const WD=["Воскресенье","Понедельник","Вторник","Среда","Четверг","Пятница","Суббота"];
const WD_SHORT=["Вс","Пн","Вт","Ср","Чт","Пт","Сб"];
const MONTHS=["января","февраля","марта","апреля","мая","июня","июля","августа","сентября","октября","ноября","декабря"];
let ALL=[],PAGE="today",LOADING=true,ERROR=null,LAST_UPDATE="";
let SELECTED_DAY=null;
function pd(n){return String(n).padStart(2,"0")}
function tds(d){return d.getFullYear()+"-"+pd(d.getMonth()+1)+"-"+pd(d.getDate())}
function parseDate(s){const p=s.split("-");return new Date(+p[0],+p[1]-1,+p[2])}
function dateStr(d){return pd(d.getDate())+" "+MONTHS[d.getMonth()]}
function dayOfWeekStr(d){return WD[d.getDay()]}
function pt(t){const p=t.split(":");return (+p[0])*60+(+p[1])}
function esc(s){const d=document.createElement("div");d.textContent=s==null?"":String(s);return d.innerHTML}
function cleanSubject(s){return (s||"").replace(/\s+/g," ").trim()}
function roomShort(r){const m=String(r||"").match(/\d+/);return m?m[0]:String(r||"").trim()}
function nowMin(){const n=new Date();return n.getHours()*60+n.getMinutes()}
function todayStr(){const n=new Date();return tds(n)}
function isWeekend(d){return d.getDay()===0||d.getDay()===6}
function fmtDur(m){if(m<60)return m+" мин";const h=Math.floor(m/60);const mm=m%60;return mm?h+" ч "+mm+" мин":h+" ч"}
function plural(n){const a=n%100;const b=n%10;if(a>=11&&a<=14)return"занятий";if(b===1)return"занятие";if(b>=2&&b<=4)return"занятия";return"занятий"}
function plural2(n){const a=n%100;const b=n%10;if(a>=11&&a<=14)return"пар";if(b===1)return"пара";if(b>=2&&b<=4)return"пары";return"пар"}
function lessonsOf(ds){return ALL.filter(l=>l.date===ds).sort((a,b)=>a.lesson-b.lesson)}
function getCurrentLesson(){
const nm=nowMin();const ls=lessonsOf(SELECTED_DAY);
if(SELECTED_DAY!==todayStr())return null;
for(const l of ls){const s=pt(l.started_at),e=pt(l.finished_at);if(s<=nm&&nm<e)return l}
return null
}
function getNextLesson(){
const nm=nowMin();const ls=lessonsOf(SELECTED_DAY);const cur=getCurrentLesson();
if(SELECTED_DAY!==todayStr())return null;
for(const l of ls){if(l===cur)continue;const s=pt(l.started_at);if(s>nm)return l}
return null
}
function weekRange(){
const now=new Date();const ws=new Date(now);ws.setDate(now.getDate()-((now.getDay()+6)%7));
const we=new Date(ws);we.setDate(ws.getDate()+6);
return[ws,we]
}
function weekLessons(){
const[ws]=weekRange();let n=0,mins=0;
for(let i=0;i<7;i++){const d=new Date(ws);d.setDate(ws.getDate()+i);lessonsOf(tds(d)).forEach(l=>{n++;mins+=Math.max(30,pt(l.finished_at)-pt(l.started_at))})}
return{n,mins}
}
function updateHeader(){
const ts=todayStr();
const heading=PAGE==="week"?"Неделя":(SELECTED_DAY&&SELECTED_DAY!==ts?"Просмотр дня":"Сегодня");
document.getElementById("greeting").textContent=heading;
let status="";
if(SELECTED_DAY&&SELECTED_DAY!==ts){document.getElementById("headerStatus").textContent="Просмотр: "+dayOfWeekStr(parseDate(SELECTED_DAY))+", "+dateStr(parseDate(SELECTED_DAY));return}
const cur=getCurrentLesson();const nxt=getNextLesson();const ls=lessonsOf(ts);
if(cur){const rem=Math.max(0,pt(cur.finished_at)-nowMin());status="Идёт пара · осталось "+fmtDur(rem)}
else if(nxt){const wait=Math.max(0,pt(nxt.started_at)-nowMin());status="Следующая через "+fmtDur(wait)}
else if(ls.length){status="На сегодня всё"}
else if(isWeekend(new Date())){status="Выходные"}
else{status="Пока нет расписания"}
document.getElementById("headerStatus").textContent=status;
}
function switchPage(p){
PAGE=p;
document.querySelectorAll(".nav-item,.mob-nav").forEach(b=>b.classList.toggle("active",b.dataset.page===p));
if(p==="today")SELECTED_DAY=null;
render();
}
function toggleTheme(){
const dark=document.documentElement.getAttribute("data-theme")==="dark";
const next=dark?"light":"dark";
document.documentElement.setAttribute("data-theme",next);
localStorage.setItem("triron-theme",next);
updateThemeUI(next);
}
function updateThemeUI(theme){
const isDark=theme==="dark";
const t=document.getElementById("themeText");if(t)t.textContent=isDark?"Светлая тема":"Тёмная тема";
const ic=document.getElementById("themeIcon");if(ic)ic.innerHTML=isDark?'<circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>':'<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>';
const mi=document.getElementById("mobThemeIcon");if(mi)mi.innerHTML=document.getElementById("themeIcon").innerHTML;
}
function stripHtml(){
const[ws,we]=weekRange();const ts=todayStr();
let h='<div class="strip"><div class="strip-label"><h2>Неделя</h2><span class="strip-range">'+dateStr(ws)+' — '+dateStr(we)+'</span></div><div class="strip-grid">';
for(let i=0;i<7;i++){
const d=new Date(ws);d.setDate(ws.getDate()+i);const ds=tds(d);const cnt=lessonsOf(ds).length;
const wk=isWeekend(d);const isToday=ds===ts;const isSel=ds===SELECTED_DAY;
let cls="strip-day"+(wk?" wknd":"")+(isToday?" today":"")+(isSel?" selected":"");
h+='<button class="'+cls+'" onclick="selectDay(\''+ds+'\')"><span class="sd-name">'+WD_SHORT[d.getDay()]+'</span><span class="sd-num">'+d.getDate()+'</span><span class="sd-cnt">'+(cnt?cnt+" "+plural2(cnt):"—")+'</span></button>';
}
h+='</div></div>';
return h;
}
function selectDay(ds){
SELECTED_DAY=ds;
render();
}
function renderMiniStats(){
const ts=todayStr();const tc=lessonsOf(ts).length;
const done=lessonsOf(ts).filter(l=>pt(l.finished_at)<=nowMin()).length;
const left=Math.max(0,tc-done);
const w=weekLessons();
return '<div class="minim">'
+'<div class="mini"><b>'+tc+'</b><span>Сегодня</span></div>'
+'<div class="mini"><b><i>'+left+'</i></b><span>Осталось</span></div>'
+'<div class="mini"><b>'+w.n+'</b><span>За неделю</span></div>'
+'<div class="mini"><b>'+(w.mins>=60?Math.round(w.mins/60):0)+'</b><span>Часов занятий</span></div>'
+'</div>';
}
const ST_ART_SVG='<svg class="st-art" viewBox="0 0 120 120" fill="none" stroke="currentColor" stroke-width="1.2"><rect x="30" y="10" width="60" height="78" rx="8"/><line x1="30" y1="28" x2="90" y2="28"/><circle cx="44" cy="46" r="2.5"/><circle cx="60" cy="46" r="2.5"/><circle cx="76" cy="46" r="2.5"/><circle cx="44" cy="64" r="2.5"/><circle cx="60" cy="64" r="2.5"/><circle cx="76" cy="64" r="2.5"/><line x1="47" y1="7" x2="47" y2="17"/><line x1="73" y1="7" x2="73" y2="17"/><path d="M26 98l10-10M36 98L26 88M94 106l8-8M94 98l8 8"/></svg>';
function renderStatus(){
const nm=nowMin();const ts=todayStr();const isSelToday=SELECTED_DAY===ts;
const ls=lessonsOf(SELECTED_DAY);const cur=getCurrentLesson();const nxt=getNextLesson();
const d=parseDate(SELECTED_DAY);const wk=isWeekend(d);
const dayTitle=dayOfWeekStr(d)+", "+dateStr(d);
let cls="status";let inner="";
if(wk&&!ls.length&&isSelToday){
cls+=" st-weekend";
inner='<div class="st-day"><b>'+dayOfWeekStr(d).toUpperCase()+'</b> · '+dateStr(d).toUpperCase()+'</div>'
+'<div class="st-top"><span class="st-badge b-quiet">Выходные</span></div>'
+'<div class="st-msg">Сегодня занятий нет</div>'
+'<div class="st-sub">День свободный — отдохни и подготовься к парам</div>'
+ST_ART_SVG;
}
else if(!ls.length){
cls+=" st-weekend";
inner='<div class="st-day"><b>'+dayOfWeekStr(d).toUpperCase()+'</b> · '+dateStr(d).toUpperCase()+'</div>'
+'<div class="st-top"><span class="st-badge b-quiet">'+(wk?"Выходные":"Пока нет расписания")+'</span></div>'
+'<div class="st-msg">'+(wk?"Выходные":"Пока нет расписания")+'</div>'
+'<div class="st-sub">'+(wk?"Занятий нет":"Расписание для этого дня ещё не появилось или день свободный")+'</div>'
+ST_ART_SVG;
}
else if(isSelToday&&cur){
cls+=" st-live";
const rem=Math.max(0,pt(cur.finished_at)-nm);const tot=Math.max(1,pt(cur.finished_at)-pt(cur.started_at));const pr=Math.max(0,Math.min(100,(nm-pt(cur.started_at))/tot*100));
inner='<div class="st-day"><b>'+dayOfWeekStr(d).toUpperCase()+'</b> · '+dateStr(d).toUpperCase()+'</div>'
+'<div class="st-top"><span class="st-badge b-live"><i></i>Идёт сейчас</span><span class="st-badge b-quiet">Пара '+cur.lesson+'</span></div>'
+'<div class="st-time">'+cur.started_at+' — '+cur.finished_at+'</div>'
+'<div class="st-subj">'+esc(cleanSubject(cur.subject_name))+'</div>'
+'<div class="st-meta"><span><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>'+esc(cur.teacher_name||"—")+'</span>'
+(cur.room_name?'<span><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>Кабинет '+esc(roomShort(cur.room_name))+'</span>':"")
+'</div>'
+'<div class="st-countdown">Осталось '+fmtDur(rem)+'</div>'
+'<div class="st-progress"><div style="width:'+pr+'%"></div></div>'
+(nxt?'<div class="st-nextline">Следующая<span><b>'+nxt.started_at+'</b></span><span>'+esc(cleanSubject(nxt.subject_name))+'</span><span class="tl-badge tl-next">'+nxt.lesson+' пара</span></div>':"");
}
else if(isSelToday&&nxt){
cls+=" st-next";
const wait=Math.max(0,pt(nxt.started_at)-nm);
inner='<div class="st-day"><b>'+dayOfWeekStr(d).toUpperCase()+'</b> · '+dateStr(d).toUpperCase()+'</div>'
+'<div class="st-top"><span class="st-badge b-next">Следующая пара</span><span class="st-badge b-quiet">Пара '+nxt.lesson+'</span></div>'
+'<div class="st-time">'+nxt.started_at+' — '+nxt.finished_at+'</div>'
+'<div class="st-subj">'+esc(cleanSubject(nxt.subject_name))+'</div>'
+'<div class="st-meta"><span><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>'+esc(nxt.teacher_name||"—")+'</span>'
+(nxt.room_name?'<span><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>Кабинет '+esc(roomShort(nxt.room_name))+'</span>':"")
+'</div>'
+'<div class="st-countdown">Начало через '+fmtDur(wait)+'</div>';
}
else if(isSelToday){
cls+=" st-done";
inner='<div class="st-day"><b>'+dayOfWeekStr(d).toUpperCase()+'</b> · '+dateStr(d).toUpperCase()+'</div>'
+'<div class="st-top"><span class="st-badge b-done">На сегодня всё</span></div>'
+'<div class="st-msg">Сегодня было '+ls.length+' '+plural(ls.length)+'</div>'
+'<div class="st-sub">Последняя пара закончилась в '+ls[ls.length-1].finished_at+'</div>';
}
else{
cls+=" st-quiet";
const first=ls[0];
inner='<div class="st-day"><b>'+dayOfWeekStr(d).toUpperCase()+'</b> · '+dateStr(d).toUpperCase()+'</div>'
+'<div class="st-top"><span class="st-badge b-quiet">'+ls.length+' '+plural(ls.length)+'</span></div>'
+'<div class="st-msg">'+dayTitle+'</div>'
+'<div class="st-sub">Первая пара в '+first.started_at+'</div>'
+ST_ART_SVG;
}
return '<div class="'+cls+'">'+inner+'</div>';
}
function renderTimeline(){
const nm=nowMin();const ts=todayStr();const ls=lessonsOf(SELECTED_DAY);
if(!ls.length)return "";
const cur=getCurrentLesson();const nxt=getNextLesson();
let h='<div class="header-tl"><span>Расписание дня</span><small>'+ls.length+' '+plural(ls.length)+'</small></div><div class="timeline">';
ls.forEach((l,idx)=>{
const s=pt(l.started_at),e=pt(l.finished_at);
let state="upcoming";
if(SELECTED_DAY<ts)state="past";
else if(SELECTED_DAY===ts){if(cur===l)state="current";else if(e<=nm)state="past"}
const tt=state==="past"?"past":"";
let mk='<span class="mk">○</span>';
let badge="";
if(state==="current"){mk='<span class="mk cur">→</span>';badge='<span class="tl-badge tl-live">Идёт сейчас</span>'}
else if(state==="upcoming"&&nxt===l&&SELECTED_DAY===ts)badge='<span class="tl-badge tl-next">Следующая</span>';
else if(state==="past")mk='<span class="mk done">✓</span>';
h+='<div class="tl '+state+' anim" style="animation-delay:'+(idx*40)+'ms">'
+'<div class="tl-time '+tt+'">'+l.started_at+'</div>'
+'<div class="tl-line"><span class="dot"></span></div>'
+'<div class="tl-card" onclick="openLesson(\''+esc(JSON.stringify({s:cleanSubject(l.subject_name),t:l.teacher_name,r:l.room_name,p:l.lesson,d:l.date,a:l.started_at+' — '+l.finished_at}).replace(/"/g,'&quot;'))+'\')">'
+'<div class="tl-top"><span class="tl-para"><span class="mk">'+mk+'</span>'+l.lesson+' пара</span><span class="tl-range">'+l.started_at+' — '+l.finished_at+'</span>'+badge+'</div>'
+'<div class="tl-subj">'+esc(cleanSubject(l.subject_name))+'</div>'
+'<div class="tl-teacher">'+esc(l.teacher_name||"")+'</div>'
+(l.room_name?'<span class="tl-room">Кабинет '+esc(roomShort(l.room_name))+'</span>':"")
+'</div></div>';
});
return h+'</div>';
}
function renderDayProgress(){
const nm=nowMin();const ls=lessonsOf(SELECTED_DAY);
if(SELECTED_DAY!==todayStr()||!ls.length)return "";
const done=ls.filter(l=>pt(l.finished_at)<=nm).length;
const pct=Math.round(done/ls.length*100);
const left=ls.length-done;
return '<div class="day-progress"><div class="dp-bar"><div style="width:'+pct+'%"></div></div><div class="dp-label">'+done+' из '+ls.length+' · осталось '+left+'</div></div>';
}
function renderTomorrow(){
const now=new Date();const td=new Date(now);td.setDate(now.getDate()+1);const ds=tds(td);const ls=lessonsOf(ds);
const wk=isWeekend(td);
let body;
if(wk)body='<div class="tom-note">Выходные — занятий нет</div>';
else if(!ls.length)body='<div class="tom-note">Пока нет расписания</div>';
else body=ls.map(l=>'<div class="tom-row" onclick="openLesson(\''+esc(JSON.stringify({s:cleanSubject(l.subject_name),t:l.teacher_name,r:l.room_name,p:l.lesson,d:l.date,a:l.started_at+' — '+l.finished_at}).replace(/"/g,'&quot;'))+'\')"><span class="tom-time">'+l.started_at+'</span><span class="tom-subj">'+esc(cleanSubject(l.subject_name))+'</span><span class="tl-badge tl-next" style="margin-left:auto">'+roomShort(l.room_name)+'</span></div>').join("");
const cnt=ls.length?ls.length+' '+plural2(ls.length):'';
return '<div class="tomorrow fadein"><div class="t-head"><span>Завтра</span><small>'+dayOfWeekStr(td)+', '+dateStr(td)+(cnt?' · '+cnt:'')+'</small></div><div class="t-rowc">'+body+'</div><button class="tom-btn" onclick="selectDay(\''+ds+'\')">→ Посмотреть расписание</button></div>';
}
function emptyState(title,sub){
return '<div class="empty"><div class="e-t">'+title+'</div><div class="e-s">'+sub+'</div></div>';
}
function renderToday(){
SELECTED_DAY=SELECTED_DAY||todayStr();
let h=stripHtml();
if(LOADING){h+='<div class="sk-status skeleton"></div><div class="sk-tl skeleton"></div><div class="sk-tl skeleton"></div>';return h}
h+=renderMiniStats();
h+=renderStatus();
h+=renderDayProgress();
h+=renderTimeline()||(isWeekend(parseDate(SELECTED_DAY))?emptyState("Выходные","Сегодня занятий нет"):emptyState("Пока нет расписания","Расписание для этого дня ещё не появилось"));
if(SELECTED_DAY===todayStr())h+=renderTomorrow();
return h;
}
function renderWeek(){
SELECTED_DAY=SELECTED_DAY||todayStr();
const[ws]=weekRange();
let h=stripHtml();
if(LOADING){h+='<div class="sk-tl skeleton"></div><div class="sk-tl skeleton"></div>';return h}
let wc=0;for(let i=0;i<7;i++){const d=new Date(ws);d.setDate(ws.getDate()+i);wc+=lessonsOf(tds(d)).length}
h+='<div class="week-days">';
for(let i=0;i<7;i++){
const d=new Date(ws);d.setDate(ws.getDate()+i);const ds=tds(d);const c=lessonsOf(ds);const wk=isWeekend(d);
let head='<div class="wd'+(ds===todayStr()?" today":"")+'"><div class="wd-head"><b>'+WD_SHORT[d.getDay()]+'</b><span>'+pd(d.getDate())+'</span>'+(c.length?'<span class="wd-cnt">'+c.length+' '+plural(c.length)+'</span>':"")+'</div>';
if(!c.length)head+='<div class="wd-empty">'+(wk?"Выходные":"Пока нет расписания")+'</div>';
else head+='<div class="wd-row">' + c.map(l=>'<div class="tom-row" onclick="openLesson(\''+esc(JSON.stringify({s:cleanSubject(l.subject_name),t:l.teacher_name,r:l.room_name,p:l.lesson,d:l.date,a:l.started_at+" — "+l.finished_at}).replace(/"/g,'&quot;'))+'\')"><span class="tom-time">'+l.started_at+'</span><span class="tom-subj">'+esc(cleanSubject(l.subject_name))+'</span></div>').join("")+'</div>';
h+=head+'</div>';
}
h+='</div>';
h+=renderStatus();
h+=renderTimeline().replace('Расписание дня',dayOfWeekStr(parseDate(SELECTED_DAY))+', '+dateStr(parseDate(SELECTED_DAY)));
return h;
}
function render(){
updateHeader();
let html="";
if(PAGE==="week")html=renderWeek();
else html=renderToday();
const box=document.getElementById("errorBox");
if(ERROR){
box.innerHTML='<div class="error-state anim"><div class="error-state-title">Не удалось обновить расписание</div><div class="error-state-sub">'+esc(ERROR)+'</div><button class="btn-retry" onclick="doUpdate()">Повторить</button></div>';
}else box.innerHTML="";
document.getElementById("pageContent").innerHTML=html;
timeAgo();
}
function timeAgo(){
const el=document.getElementById("lastUpdate");
if(!LAST_UPDATE){el.textContent="";return}
const now=new Date();
const p=LAST_UPDATE.split(":").map(Number);
const then=new Date(now);then.setHours(p[0],p[1],p[2]||0,0);
let diff=Math.round((now-then)/1000);
if(diff<60)el.textContent="Обновлено только что";
else if(diff<3600)el.textContent="Обновлено "+Math.floor(diff/60)+" мин назад";
else el.textContent="Обновлено "+Math.floor(diff/3600)+" ч назад";
}
function toast(m,type){
const el=document.getElementById("toast");
el.textContent=m;el.className="toast "+(type||"")+" show";
setTimeout(()=>el.className="toast",3500);
}
function highlight(text,q){
const t=String(text||"");const qn=String(q||"").trim().toLowerCase().replace(/\s+/g,"");
if(!qn)return esc(t);
let norm="";
for(let k=0;k<t.length;k++){if(!/\s/.test(t[k]))norm+=t[k].toLowerCase()}
const i=norm.indexOf(qn);
if(i<0)return esc(t);
const idx=[];let count=0;
for(let k=0;k<t.length;k++){
if(/\s/.test(t[k]))continue;
if(count>=i&&count<i+qn.length)idx.push(k);
count++;
}
const s0=idx[0],s1=idx[idx.length-1];
return esc(t.slice(0,s0))+'<mark>'+esc(t.slice(s0,s1+1))+'</mark>'+esc(t.slice(s1+1));
}
function renderSearchResults(q){
const box=document.getElementById("searchResults");
const qi=String(q||"").trim().toLowerCase().replace(/\s+/g,"");
if(!qi){box.classList.remove("show");box.innerHTML="";return}
const res=[];
ALL.forEach(l=>{
const subj=cleanSubject(l.subject_name),teach=(l.teacher_name||""),room=String(l.room_name||"");
const hay=((subj+" "+teach+" "+room).toLowerCase()).replace(/\s+/g,"");
if(hay.indexOf(qi)>=0)res.push(l);
});
if(!res.length){box.innerHTML='<div class="sr-hint">Ничего не найдено по запросу «'+esc(q)+'»</div>';box.classList.add("show");return}
box.innerHTML=res.slice(0,12).map(l=>{
const payload=esc(JSON.stringify({s:cleanSubject(l.subject_name),t:l.teacher_name,r:l.room_name,p:l.lesson,d:l.date,a:l.started_at+' — '+l.finished_at}).replace(/"/g,'&quot;'));
return '<button class="sr-item fadein" onclick="searchPick(\''+payload+'\')">'
+'<b>'+highlight(cleanSubject(l.subject_name),qi)+'</b>'
+'<span class="sr-meta"><span><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>'+dateStr(parseDate(l.date))+' · '+WD_SHORT[parseDate(l.date).getDay()]+'</span>'
+'<span><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>'+l.started_at+'</span>'
+'<span><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>'+esc(roomShort(l.room_name))+'</span></span>'
+'</button>';
}).join("");
box.classList.add("show");
}
function openSearchBox(){
const i=document.getElementById("searchInput");
i.focus();i.select();
renderSearchResults(i.value);
}
function closeSearchBox(){
const i=document.getElementById("searchInput");
const box=document.getElementById("searchResults");
i.value="";box.classList.remove("show");box.innerHTML="";
i.blur();
}
function searchPick(json){
try{
const d=JSON.parse(json.replace(/&quot;/g,'"'));
SELECTED_DAY=d.d;PAGE="today";
document.querySelectorAll(".nav-item,.mob-nav").forEach(b=>b.classList.toggle("active",b.dataset.page==="today"));
closeSearchBox();
render();
openLesson(json);
}catch(e){}
}
function openLesson(json){
try{
const d=JSON.parse(json.replace(/&quot;/g,'"'));
document.getElementById("modalTitle").textContent=d.s;
document.getElementById("modalSub").textContent=(d.d?d.d+" · ":"")+"Пара "+d.p;
document.getElementById("modalBody").innerHTML=
'<div class="modal-row"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg><span>Время</span><strong>'+d.a+'</strong></div>'
+'<div class="modal-row"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg><span>Преподаватель</span><strong>'+esc(d.t||"—")+'</strong></div>'
+'<div class="modal-row"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg><span>Кабинет</span><strong>'+esc(d.r||"—")+'</strong></div>';
document.getElementById("modalBackdrop").classList.add("show");
document.getElementById("modal").classList.add("show");
}catch(e){}
}
function closeModal(){
document.getElementById("modalBackdrop").classList.remove("show");
document.getElementById("modal").classList.remove("show");
}
async function load(){
LOADING=true;render();
try{
const r=await fetch("/api/schedule");
if(!r.ok)throw new Error("HTTP "+r.status);
const data=await r.json();
ALL=Array.isArray(data)?data:[];
LOADING=false;render();
}catch(e){
LOADING=false;ERROR=e.message;render();
toast("Ошибка загрузки","error");
}
}
async function pollStatus(){
try{
const r=await fetch("/api/status");
const s=await r.json();
const btn=document.getElementById("refreshBtn");
const txt=document.getElementById("refreshText");
if(s.running){btn.classList.add("spin");btn.disabled=true;txt.textContent="Обновляется...";}
else{btn.classList.remove("spin");btn.disabled=false;txt.textContent="Обновить";}
if(s.last_update){LAST_UPDATE=s.last_update;timeAgo();}
if(s.last_error){ERROR=s.last_error;render();}
}catch(e){}
}
async function doUpdate(){
try{
const r=await fetch("/api/update",{method:"POST"});
const j=await r.json();
if(!j.ok){toast(j.message,"error");return}
toast("Обновление запущено","success");
const t=setInterval(async()=>{
await pollStatus();
await loadLogs(true);
const btn=document.getElementById("refreshBtn");
if(!btn.classList.contains("spin")){clearInterval(t);await load();await loadLogs(true);}
},1500);
}catch(e){toast("Ошибка","error")}
}
function toggleLogs(){
const p=document.getElementById("logPanel");
p.classList.toggle("open");
if(p.classList.contains("open"))loadLogs(true);
}
async function loadLogs(force){
try{
const panel=document.getElementById("logPanel");
const body=document.getElementById("logList");
const badge=document.getElementById("logBadge");
if(!panel||(!panel.classList.contains("open")&&!force))return;
const r=await fetch("/api/logs");
const logs=await r.json();
badge.textContent=logs.length||"0";
if(!logs.length){body.innerHTML='<div class="log-empty">Пока записей нет — обновите расписание</div>';return}
body.innerHTML=logs.slice().reverse().map(e=>{
const lvl=e.level==="ok"?"ok":e.level==="warning"?"warning":e.level==="error"?"error":"info";
return '<div class="log-item '+lvl+'"><span class="log-dot"></span><span class="log-time">'+esc(e.time)+'</span><span class="log-text">'+esc(e.text)+'</span></div>';
}).join("");
}catch(e){}
}
document.addEventListener("keydown",e=>{
if(e.key==="Escape"){if(document.getElementById("searchResults").classList.contains("show")||document.activeElement===document.getElementById("searchInput")){closeSearchBox();e.preventDefault();}else closeModal();return}
if(e.target.matches("input")||e.target.matches("textarea"))return;
if(e.key==="/"&&!e.ctrlKey&&!e.metaKey&&!e.altKey){e.preventDefault();openSearchBox();return}
if(e.key.toLowerCase()==="r"&&!e.ctrlKey&&!e.metaKey){e.preventDefault();doUpdate()}
if(e.key.toLowerCase()==="t"&&!e.ctrlKey&&!e.metaKey){e.preventDefault();switchPage("today")}
});
document.getElementById("searchInput").addEventListener("input",e=>renderSearchResults(e.target.value));
document.getElementById("searchInput").addEventListener("focus",()=>{renderSearchResults(document.getElementById("searchInput").value)});
document.addEventListener("click",e=>{
const s=document.querySelector(".search");
if(s&&!s.contains(e.target))document.getElementById("searchResults").classList.remove("show");
});
(function(){
const saved=localStorage.getItem("triron-theme");
const pref=window.matchMedia("(prefers-color-scheme:dark)").matches?"dark":"light";
const theme=saved||pref;
document.documentElement.setAttribute("data-theme",theme);
updateThemeUI(theme);
})();
setInterval(timeAgo,30000);
setInterval(()=>{if(PAGE==="today"){updateHeader();render();}},35000);
updateHeader();
load();
loadLogs(true);
setInterval(pollStatus,10000);
setInterval(()=>loadLogs(true),30000);
</script>
</body>
</html></html>"""
def handle_signal(signum, frame):
log.info("Signal %s received. Shutting down...", signum)
try:
os._exit(0)
except Exception:
pass
signal.signal(signal.SIGINT, handle_signal)
signal.signal(signal.SIGTERM, handle_signal)
if __name__ == "__main__":
log.info("=" * 50)
log.info("Schedule Web Server starting...")
log.info("Session file: %s (exists: %s)", SESSION_FILE, SESSION_FILE.exists())
log.info("Schedule file: %s (exists: %s)", SCHEDULE_FILE, SCHEDULE_FILE.exists())
log.info("Log file: %s", LOG_FILE)
log.info("Server: http://127.0.0.1:5000")
log.info("=" * 50)
log_event("info", "Сервис запущен. Обновление расписания выполняется автоматически.")
try:
app.run(host="0.0.0.0", port=5000, debug=False)
except Exception as e:
log.critical("Server crashed: %s", e, exc_info=True)
finally:
log.info("Server stopped.")
logging.shutdown()