schedule-web/PythonApplication1.py

1574 lines
83 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

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>
<style>
:root{
/* GitHub Primer palette */
--bg:#f0f6fc;--surface:#ffffff;--surface-2:#f6f8fa;--surface-3:#eaeef2;
--border:#d0d7de;--border-strong:#afb8c1;
--text:#000000;--text-2:#57606a;--text-3:#6e7781;
--accent:#1f6feb;--accent-fg:#1158c7;--accent-dark:#1158c7;
--accent-soft:#ddf4ff;--accent-softer:rgba(31,111,235,.06);--accent-ring:rgba(31,111,235,.3);
--secondary:#a4aea6;
--cyan:#54aeff;--cyan-soft:#b6e3ff;
--success:#08872b;--success-fg:#0f5323;--success-soft:#dafbe1;
--warn:#9a6700;--warn-fg:#633c01;--warn-soft:#fff8c5;
--danger:#cf222e;--danger-soft:#ffebe9;
--shadow-sm:0 1px 0 rgba(31,35,40,.04);
--shadow-md:0 3px 6px rgba(140,149,159,.15);
--shadow-lg:0 8px 24px rgba(140,149,159,.18);
--sidebar-w:242px;--radius:8px;--radius-sm:6px;--radius-xs:6px;
--hero-glow:rgba(31,111,235,.09);
--grid-line:rgba(31,111,235,.07)
}
[data-theme="dark"]{
--bg:#0d1117;--surface:#161b22;--surface-2:#1c2128;--surface-3:#262c36;
--border:#30363d;--border-strong:#444c56;
--text:#e6edf3;--text-2:#9198a1;--text-3:#7d8590;
--accent:#1f6feb;--accent-fg:#79c0ff;--accent-dark:#4493f8;
--accent-soft:rgba(84,174,255,.15);--accent-softer:rgba(88,166,255,.07);--accent-ring:rgba(121,192,255,.4);
--secondary:#6e7781;
--cyan:#0076ff;--cyan-soft:rgba(0,118,255,.15);
--success:#3fb950;--success-fg:#3fb950;--success-soft:rgba(63,185,80,.15);
--warn:#d29922;--warn-fg:#e3b341;--warn-soft:rgba(210,153,34,.15);
--danger:#f85149;--danger-soft:rgba(248,81,73,.15);
--shadow-sm:0 1px 0 rgba(1,4,9,.5);
--shadow-md:0 3px 6px rgba(1,4,9,.55);
--shadow-lg:0 8px 24px rgba(1,4,9,.7);
--hero-glow:rgba(0,118,255,.18);
--grid-line:rgba(121,192,255,.05)
}
*{box-sizing:border-box;margin:0;padding:0}
html{scroll-behavior:smooth}
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","Noto Sans",Helvetica,Arial,sans-serif,"Apple Color Emoji";color:var(--text);font-size:14px;line-height:1.5;overflow-x:hidden;-webkit-font-smoothing:antialiased;min-height:100vh;background:var(--bg)}
a{color:var(--accent-fg);text-decoration:none}
a:hover{text-decoration:underline}
button{font-family:inherit;font-size:inherit;line-height: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:6px}
/* SIDEBAR */
.sidebar{position:fixed;top:0;left:0;bottom:0;width:var(--sidebar-w);background:#1f2328;border-right:1px solid #151a1f;display:flex;flex-direction:column;z-index:100}
[data-theme="dark"] .sidebar{background:#010409;border-right-color:#21262d}
.sidebar-brand{padding:20px 18px 16px;border-bottom:1px solid #2f353b}
[data-theme="dark"] .sidebar-brand{border-bottom-color:#21262d}
.sidebar-brand h1{font-size:18px;font-weight:600;letter-spacing:.01em;color:#ffffff}
.sidebar-brand h1 b{color:#79c0ff}
.sidebar-brand .sb-dot{display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--accent);margin-left:4px;vertical-align:middle;animation:blink 2.2s ease-in-out infinite}
.sidebar-brand span{display:block;font-size:11px;color:#8b949e;margin-top:3px}
.sidebar-nav{flex:1;padding:12px 10px;overflow-y:auto}
.nav-item{display:flex;align-items:center;gap:11px;width:100%;padding:8px 12px;border:none;background:none;border-radius:var(--radius-sm);color:#adbac7;font-size:14px;font-weight:500;text-align:left;transition:background .1s,color .1s}
.nav-item:hover{background:rgba(255,255,255,.06);color:#ffffff}
.nav-item.active{background:rgba(31,111,235,.18);color:#ffffff;font-weight:600}
.nav-item svg{width:16px;height:16px;flex-shrink:0;opacity:.85}
.nav-item.active svg{opacity:1}
.sidebar-foot{padding:10px;border-top:1px solid #2f353b}
[data-theme="dark"] .sidebar-foot{border-top-color:#21262d}
.theme-toggle{display:flex;align-items:center;gap:11px;width:100%;padding:8px 12px;border:none;background:none;border-radius:var(--radius-sm);color:#adbac7;font-size:14px;font-weight:500;transition:background .1s,color .1s}
.theme-toggle:hover{background:rgba(255,255,255,.06);color:#ffffff}
.theme-toggle svg{width:16px;height:16px;opacity:.85}
.sidebar-src{padding:4px 12px 2px;font-size:11px;color:#8b949e}
/* 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:11px;font-weight:500}
.mob-nav.active{color:var(--accent-fg);font-weight:600}
.mob-nav svg{width:18px;height:18px}
/* MAIN */
.main{margin-left:var(--sidebar-w);min-height:100vh;padding:32px 32px 96px}
.main-inner{max-width:924px;margin:0 auto}
/* HEADER */
.header{margin-bottom:24px}
.header-row{display:flex;align-items:flex-start;justify-content:space-between;gap:16px}
.header-greeting{font-size:24px;font-weight:600;letter-spacing:-.01em;line-height:1.25;color:var(--text)}
.header-status{font-size:14px;color:var(--text-2);margin-top:4px;font-weight:400}
.header-actions{display:flex;flex-direction:column;align-items:flex-end;gap:5px}
.btn-refresh{display:inline-flex;align-items:center;gap:6px;padding:5px 16px;background:var(--surface-2);border:1px solid var(--border);border-radius:var(--radius-sm);font-size:14px;font-weight:500;color:var(--text);transition:background .1s,border-color .1s;box-shadow:var(--shadow-sm)}
.btn-refresh:hover{background:var(--surface-3);border-color:var(--border-strong)}
.btn-refresh:disabled{opacity:.6;cursor:default}
.btn-refresh.spin svg{animation:spin .7s linear infinite}
.btn-refresh svg{width:16px;height:16px}
@keyframes spin{to{transform:rotate(360deg)}}
.last-update{font-size:12px;color:var(--text-3);font-variant-numeric:tabular-nums}
/* SEARCH */
.search{position:relative;margin-top:16px}
.search input{width:100%;padding:8px 40px 8px 36px;border:1px solid var(--border);background:var(--surface);border-radius:var(--radius-sm);font-size:14px;line-height:20px;color:var(--text);transition:border-color .1s,box-shadow .1s}
.search input::placeholder{color:var(--text-3);opacity:1}
.search input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-ring)}
.search .s-ic{position:absolute;left:11px;top:50%;transform:translateY(-50%);width:16px;height:16px;color:var(--text-3);pointer-events:none}
.search .s-kbd{position:absolute;right:10px;top:50%;transform:translateY(-50%);font-size:12px;color:var(--text-3);border:1px solid var(--border);border-radius:var(--radius-sm);padding:1px 6px;background:var(--surface-2);pointer-events:none;line-height:18px}
.search-results{position:absolute;top:calc(100% + 6px);left:0;right:0;background:var(--surface);border:1px solid var(--border);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:10px 12px;font-size:13px;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 .1s}
.sr-item:last-child{border-bottom:none}
.sr-item:hover{background:var(--accent-soft)}
.sr-item b{display:block;font-size:14px;font-weight:600;color:var(--text)}
.sr-item mark{background:var(--warn-soft);color:var(--warn-fg);border-radius:3px;padding:0 2px}
.sr-meta{display:flex;flex-wrap:wrap;gap:4px 14px;margin-top:2px;font-size:12px;color:var(--text-3)}
.sr-meta span{display:inline-flex;align-items:center;gap:4px}
.sr-meta svg{width:12px;height:12px;opacity:.75}
/* DAY STRIP */
.strip{margin:0 0 24px}
.strip-label{display:flex;align-items:baseline;justify-content:space-between;margin-bottom:8px}
.strip-label h2{font-size:14px;font-weight:600;letter-spacing:0;color:var(--text-2)}
.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:8px 4px;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-sm);text-align:center;transition:border-color .1s,background .1s,box-shadow .1s;position:relative}
.strip-day:hover{border-color:var(--accent);box-shadow:0 0 0 1px var(--accent)}
.strip-day .sd-name{display:block;font-size:12px;letter-spacing:0;font-weight:500;color:var(--text-3)}
.strip-day .sd-num{display:block;font-size:17px;font-weight:600;color:var(--text);margin:1px 0;font-variant-numeric:tabular-nums}
.strip-day .sd-cnt{display:block;font-size:11px;font-weight:400;color:var(--text-3);font-variant-numeric:tabular-nums}
.strip-day.wknd{background:var(--surface-2);border-style:solid;opacity:1}
.strip-day.today{border-color:var(--border-strong)}
.strip-day.today .sd-name{color:var(--accent-fg);font-weight:600}
.strip-day.today .sd-num{color:var(--text)}
.strip-day.today .sd-cnt{color:var(--text-2)}
.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);box-shadow:0 0 0 1px var(--accent)}
.strip-day.selected .sd-name,.strip-day.selected .sd-num{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:8px;margin:0 0 24px}
.mini{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-sm);padding:12px 16px;box-shadow:var(--shadow-sm)}
.mini b{display:block;font-size:24px;font-weight:600;color:var(--text);font-variant-numeric:tabular-nums;line-height:1.25}
.mini b i{font-style:normal;color:var(--accent-fg)}
.mini span{font-size:12px;font-weight:400;color:var(--text-3)}
/* DAY HEAD */
.day-head{margin:4px 0 16px}
.day-head .day-title{font-size:14px;font-weight:400;color:var(--text-2)}
.day-head .day-title b{color:var(--text);font-size:16px;font-weight:600}
.day-head .day-count{font-size:13px;color:var(--text-3);margin-top:2px}
/* HERO / STATUS */
.status{position:relative;isolation:isolate;margin-bottom:16px;padding:24px;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);box-shadow:var(--shadow-sm);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.st-live{background:linear-gradient(180deg,var(--accent-softer),var(--surface) 60%);border-color:var(--accent);box-shadow:0 0 0 1px var(--accent-soft),var(--shadow-md)}
.status.st-next{background:linear-gradient(180deg,var(--warn-soft),var(--surface) 60%);border-color:#d4a72c}
[data-theme="dark"] .status.st-next{border-color:rgba(210,153,34,.5)}
.status.st-done{background:linear-gradient(180deg,var(--success-soft),var(--surface) 60%);border-color:#4ac26b}
[data-theme="dark"] .status.st-done{border-color:rgba(63,185,80,.5)}
.status.st-weekend{border-color:var(--border-strong)}
.status.st-quiet{border-color:var(--border-strong)}
.st-top{display:flex;align-items:center;gap:8px;margin-bottom:12px;flex-wrap:wrap}
.st-badge{display:inline-flex;align-items:center;gap:6px;font-size:12px;font-weight:500;line-height:18px;padding:0 8px;border:1px solid;border-radius:2em}
.st-badge i{width:6px;height:6px;border-radius:50%;background:currentColor}
.st-badge.b-live{color:var(--accent-fg);background:var(--accent-soft);border-color:var(--accent-soft)}
.st-badge.b-live i{animation:blink 1.8s ease-in-out infinite}
.st-badge.b-next{color:var(--warn-fg);background:var(--warn-soft);border-color:var(--warn-soft)}
.st-badge.b-done{color:var(--success-fg);background:var(--success-soft);border-color:var(--success-soft)}
.st-badge.b-quiet{color:var(--text-2);background:var(--surface-2);border-color:var(--border)}
@keyframes blink{0%,100%{opacity:1}50%{opacity:.25}}
.st-day{font-size:12px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--text-3);margin-bottom:8px}
.st-day b{color:var(--accent-fg);font-weight:600}
.st-time{font-size:24px;font-weight:600;letter-spacing:-.01em;line-height:1.25;color:var(--text);font-variant-numeric:tabular-nums}
.st-subj{font-size:18px;font-weight:600;color:var(--text);margin-top:4px;line-height:1.3}
.st-meta{display:flex;flex-wrap:wrap;gap:6px 18px;margin-top:10px;font-size:14px;color:var(--text-2)}
.st-meta span{display:inline-flex;align-items:center;gap:6px}
.st-meta svg{width:14px;height:14px;opacity:.6}
.st-countdown{margin-top:14px;font-size:14px;font-weight:600;color:var(--accent-fg);font-variant-numeric:tabular-nums}
.st-progress{margin-top:8px;height:6px;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:16px;padding-top:12px;border-top:1px solid var(--border);font-size:14px;color:var(--text-2);display:flex;align-items:center;gap:8px}
.st-nextline b{color:var(--text);font-weight:600}
.st-nextline .tl-badge{margin-left:auto}
.st-msg{font-size:16px;font-weight:600;color:var(--text)}
.st-sub{font-size:14px;color:var(--text-2);margin-top:4px}
.st-art{position:absolute;right:16px;bottom:10px;width:110px;height:110px;color:var(--secondary);opacity:.28;pointer-events:none;z-index:-1}
/* DAY PROGRESS */
.day-progress{display:flex;align-items:center;gap:12px;margin:0 0 24px;padding:0 2px}
.dp-bar{flex:1;height:6px;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:12px;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 12px}
.header-tl span{font-size:14px;font-weight:600;color:var(--text-2)}
.header-tl small{font-size:12px;color:var(--text-3)}
.tl{display:grid;grid-template-columns:56px 14px 1fr;gap:6px 8px;margin-bottom:8px;align-items:stretch}
.tl-time{padding-top:14px;text-align:right;font-size:13px;font-weight:600;color:var(--text);font-variant-numeric:tabular-nums}
.tl-line{position:relative}
.tl-line::before{content:'';position:absolute;left:6px;top:0;bottom:-8px;width:1px;background:var(--border)}
.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);border:2px solid var(--border-strong);box-shadow:0 0 0 2px var(--surface)}
.tl-card{padding:12px 16px;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-sm);box-shadow:var(--shadow-sm);cursor:pointer;transition:border-color .1s,box-shadow .1s;position:relative}
.tl-card:hover{border-color:var(--accent);box-shadow:0 0 0 1px var(--accent),var(--shadow-md)}
.tl-top{display:flex;align-items:center;gap:8px;margin-bottom:4px}
.tl-para{display:inline-flex;align-items:center;gap:7px;font-size:12px;font-weight:500;color:var(--text-3);font-variant-numeric:tabular-nums}
.mk{display:inline-flex;align-items:center;justify-content:center;width:16px;height:16px;border-radius:50%;font-size:10px;line-height:1;color:var(--text-3);border:1px solid var(--border-strong);background:var(--surface-2)}
.mk.done{color:var(--success-fg);border-color:var(--success);background:var(--success-soft)}
.mk.cur{color:var(--accent-fg);border-color:var(--accent);background:var(--accent-soft)}
.mk.up{color:var(--text-3)}
.tl-range{margin-left:auto;font-size:12px;color:var(--text-3);font-variant-numeric:tabular-nums}
.tl-subj{font-size:14px;font-weight:600;color:var(--text);line-height:1.35}
.tl-teacher{font-size:13px;color:var(--text-2);margin-top:2px}
.tl-room{display:inline-block;margin-top:6px;font-size:12px;font-weight:500;color:var(--accent-fg);background:var(--accent-softer);border:1px solid var(--accent-soft);padding:1px 9px;border-radius:2em}
.tl-badge{font-size:12px;font-weight:500;line-height:18px;padding:0 8px;border:1px solid;border-radius:2em;flex-shrink:0}
.tl-badge.tl-live{color:var(--accent-fg);background:var(--accent-soft);border-color:var(--accent-soft)}
.tl-badge.tl-next{color:var(--warn-fg);background:var(--warn-soft);border-color: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);background:var(--accent-soft);box-shadow:0 0 0 1px var(--accent)}
.tl.current .tl-subj{color:var(--text)}
.tl.past .tl-card{background:var(--surface-2)}
.tl.past .tl-card:hover{background:var(--surface)}
.tl.upcoming .tl-line .dot{background:var(--surface)}
.tl-time.past{color:var(--text-3)}
/* EMPTY */
.empty{padding:40px 24px;text-align:center;background:var(--surface);border:1px dashed var(--border-strong);border-radius:var(--radius);box-shadow:none}
.empty .e-t{font-size:16px;font-weight:600;color:var(--text)}
.empty .e-s{font-size:14px;color:var(--text-2);margin-top:4px}
/* TOMORROW */
.tomorrow{margin-top:24px;position:relative;background:var(--surface);border:1px solid var(--border);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:12px 16px 10px}
.tomorrow .t-head span{font-size:14px;font-weight:600;color:var(--text)}
.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 .1s}
.tom-row:first-child{border-top:none}
.tom-row:hover{background:var(--accent-soft)}
.tom-time{font-size:13px;font-weight:600;color:var(--text);font-variant-numeric:tabular-nums;width:40px;flex-shrink:0}
.tom-subj{font-size:14px;font-weight:400;color:var(--text);flex:1;min-width:0}
.tom-note{font-size:14px;color:var(--text-2);padding:12px 16px}
.tom-btn{display:block;width:100%;margin-top:0;padding:10px 16px;border:none;border-top:1px solid var(--border);background:var(--surface-2);color:var(--accent-fg);font-size:14px;font-weight:500;text-align:center;transition:background .1s}
.tom-btn:hover{background:var(--accent-soft)}
.tom-btn:active{transform:translateY(1px)}
/* CALENDAR */
.cal{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);box-shadow:var(--shadow-sm);overflow:hidden}
.cal-head{display:flex;align-items:center;gap:8px;padding:12px 16px;border-bottom:1px solid var(--border)}
.cal-nav{width:30px;height:30px;display:flex;align-items:center;justify-content:center;border:1px solid var(--border);background:var(--surface-2);border-radius:var(--radius-sm);font-size:18px;line-height:1;color:var(--text-2);transition:background .1s,border-color .1s,color .1s}
.cal-nav:hover{background:var(--surface-3);border-color:var(--border-strong);color:var(--text)}
.cal-title{flex:1;text-align:center;font-size:16px;font-weight:600;color:var(--text)}
.cal-year{color:var(--text-3);font-weight:400}
.cal-today{padding:5px 12px;background:var(--surface-2);border:1px solid var(--border);border-radius:var(--radius-sm);font-size:14px;font-weight:500;color:var(--text);transition:background .1s,border-color .1s;box-shadow:var(--shadow-sm);white-space:nowrap}
.cal-today:hover{background:var(--surface-3);border-color:var(--border-strong)}
.cal-week{display:grid;grid-template-columns:repeat(7,1fr);border-bottom:1px solid var(--border);background:var(--surface-2)}
.cal-wd{padding:6px 4px;text-align:center;font-size:12px;font-weight:500;color:var(--text-3)}
.cal-grid{display:grid;grid-template-columns:repeat(7,1fr);gap:1px;background:var(--border)}
.cal-cell{min-height:56px;padding:5px 7px;background:var(--surface);border:none;text-align:left;display:flex;flex-direction:column;align-items:flex-start;gap:3px;transition:background .1s,box-shadow .1s}
.cal-cell:hover{background:var(--accent-soft)}
.cal-cell.cal-pad{background:var(--surface-2);cursor:default}
.cal-cell.cal-pad:hover{background:var(--surface-2)}
.cal-num{font-size:13px;font-weight:500;color:var(--text-2);font-variant-numeric:tabular-nums;line-height:1.2}
.cal-cell.wknd .cal-num{color:var(--text-3)}
.cal-cell.blind{background:var(--surface-2)}
/* dimmed via the background only: opacity, or --text-3 here, would drop the
day number below AA. The missing count badge is the second signal. */
.cal-cell.blind .cal-num{color:var(--text-2)}
.cal-cell.today{box-shadow:inset 0 0 0 2px var(--accent)}
.cal-cell.today .cal-num{color:var(--accent-fg);font-weight:600}
.cal-cnt{display:inline-flex;align-items:center;justify-content:center;min-width:20px;height:20px;padding:0 6px;border-radius:2em;background:var(--surface-3);color:var(--text-2);font-size:12px;font-weight:600;line-height:1;font-variant-numeric:tabular-nums}
.cal-cell.today .cal-cnt{background:var(--accent);color:#fff}
.cal-cell.selected{background:var(--accent);box-shadow:none}
.cal-cell.selected .cal-num{color:#fff}
.cal-cell.selected .cal-cnt{background:#fff;color:var(--accent-fg)}
/* DAY AGENDA */
.cal-day{border-top:1px solid var(--border);background:var(--surface)}
.cal-day-head{display:flex;align-items:center;gap:12px;padding:14px 16px 12px}
.cal-day-name{font-size:16px;font-weight:600;color:var(--text);line-height:1.3}
.cal-day-sub{font-size:13px;color:var(--text-2);margin-top:2px}
.cal-agenda{border-top:1px solid var(--border)}
.cal-row{display:flex;align-items:flex-start;gap:14px;width:100%;padding:12px 16px;border:none;border-bottom:1px solid var(--border);background:none;text-align:left;transition:background .1s}
.cal-row:last-child{border-bottom:none}
.cal-row:hover{background:var(--accent-soft)}
.cal-row-time{flex-shrink:0;width:54px;display:flex;flex-direction:column;font-size:14px;font-weight:600;color:var(--text);font-variant-numeric:tabular-nums;line-height:1.35}
.cal-row-end{font-size:12px;font-weight:400;color:var(--text-3)}
.cal-row-body{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}
.cal-row-subj{font-size:14px;font-weight:600;color:var(--text);line-height:1.35}
.cal-row-teacher{font-size:13px;color:var(--text-2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.cal-room{flex-shrink:0;align-self:center;font-size:12px;font-weight:500;line-height:18px;color:var(--accent-fg);background:var(--accent-softer);border:1px solid var(--accent-soft);padding:2px 10px;border-radius:2em;white-space:nowrap;max-width:210px;overflow:hidden;text-overflow:ellipsis}
.cal-row-dur{flex-shrink:0;align-self:center;width:76px;text-align:right;font-size:12px;color:var(--text-3);font-variant-numeric:tabular-nums}
.cal-row.current{background:var(--accent-soft);box-shadow:inset 3px 0 0 var(--accent)}
.cal-none{padding:28px 16px;font-size:14px;color:var(--text-2);text-align:center;border-top:1px solid var(--border)}
.cal-open{display:block;width:100%;padding:11px 16px;border:none;border-top:1px solid var(--border);background:var(--surface-2);color:var(--accent-fg);font-size:14px;font-weight:500;text-align:center;transition:background .1s}
.cal-open:hover{background:var(--accent-soft)}
/* ERROR */
.error-state{padding:16px 20px;margin-bottom:16px;background:var(--danger-soft);border:1px solid var(--danger);border-radius:var(--radius);box-shadow:none}
.error-state-title{font-size:14px;font-weight:600;color:var(--danger)}
.error-state-sub{font-size:14px;color:var(--text-2);margin:4px 0 12px}
.btn-retry{display:inline-flex;align-items:center;padding:5px 16px;background:var(--danger);color:#fff;border:1px solid var(--danger);border-radius:var(--radius-sm);font-size:14px;font-weight:500;box-shadow:var(--shadow-sm)}
.btn-retry:hover{filter:brightness(.92)}
/* 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:12px;color:var(--text-3);flex-wrap:wrap;gap:6px}
.footer kbd{background:var(--surface-2);border:1px solid var(--border);border-radius:var(--radius-xs);padding:0 5px;font-size:11px;font-family:inherit}
/* LOG PANEL */
.log-panel{margin-top:16px;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:10px 16px;border:none;background:none;color:var(--text-2);font-size:14px;font-weight:600;text-align:left;transition:background .1s}
.log-head:hover{background:var(--surface-2);color:var(--text)}
.log-head svg{width:16px;height:16px;opacity:.75}
.log-badge{margin-left:4px;background:var(--accent-soft);color:var(--accent-fg);border-radius:2em;padding:0 8px;font-size:12px;font-weight:500;line-height:18px}
.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 16px;font-size:13px;line-height:1.45;color:var(--text-2)}
.log-item .log-time{color:var(--text-3);flex-shrink:0;font-size:11px;padding-top:2px;font-variant-numeric:tabular-nums}
.log-item .log-dot{flex-shrink:0;width:6px;height:6px;border-radius:50%;margin-top:5px}
.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:14px 16px;font-size:13px;color:var(--text-3);text-align:center}
/* ANIM */
@keyframes fadeUp{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}
.anim{animation:fadeUp .2s ease-out both}
@keyframes fadeIn{from{opacity:0}to{opacity:1}}
.fadein{animation:fadeIn .15s ease-out both}
/* MODAL */
.modal-backdrop{position:fixed;inset:0;background:rgba(31,35,40,.5);z-index:200;opacity:0;pointer-events:none;transition:opacity .2s}
[data-theme="dark"] .modal-backdrop{background:rgba(1,4,9,.8)}
.modal-backdrop.show{opacity:1;pointer-events:auto}
.modal{position:fixed;top:50%;left:50%;transform:translate(-50%,-50%) scale(.98);z-index:201;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:24px;width:92%;max-width:448px;box-shadow:var(--shadow-lg);opacity:0;pointer-events:none;transition:opacity .12s,transform .12s ease-out}
.modal.show{opacity:1;transform:translate(-50%,-50%) scale(1);pointer-events:auto}
.modal-close{position:absolute;top:12px;right:12px;width:28px;height:28px;border-radius:var(--radius-sm);border:none;background:var(--surface-2);color:var(--text-2);font-size:18px;line-height:1;display:flex;align-items:center;justify-content:center;transition:background .1s}
.modal-close:hover{background:var(--surface-3);color:var(--text)}
.modal-title{font-size:16px;font-weight:600;color:var(--text);padding-right:32px;line-height:1.3}
.modal-sub{font-size:12px;color:var(--text-3);margin-top:4px}
.modal-body{margin-top:16px}
.modal-row{display:flex;align-items:center;gap:10px;padding:10px 0;border-bottom:1px solid var(--border);font-size:14px}
.modal-row:last-child{border-bottom:none}
.modal-row svg{width:16px;height:16px;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:24px;right:24px;padding:10px 16px;background:var(--accent-soft);border:1px solid var(--accent);border-radius:var(--radius-sm);font-size:14px;color:var(--text);box-shadow:var(--shadow-md);z-index:1000;opacity:0;transform:translateY(8px);pointer-events:none;transition:opacity .2s,transform .2s}
.toast.show{opacity:1;transform:none}
.toast.success{background:var(--success-soft);border-color:#4ac26b;color:var(--text)}
.toast.error{background:var(--danger-soft);border-color:var(--danger);color:var(--text)}
/* 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,var(--accent-softer),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:8px}
/* RESPONSIVE */
@media(max-width:820px){
.main{margin-left:0;padding:20px 14px 88px}
.mobile-nav{display:block}
.sidebar{display:none}
.header-row{align-items:flex-start}
.header-greeting{font-size:20px}
.strip-grid{gap:4px}
.strip-day{padding:6px 2px}
.strip-day .sd-num{font-size:15px}
.tl{grid-template-columns:44px 12px 1fr}
.tl-time{font-size:12px}
.st-time{font-size:20px}
.st-subj{font-size:16px}
.minim{grid-template-columns:repeat(2,1fr);gap:8px}
.st-art{right:6px;bottom:0;width:84px;height:84px}
.cal-head{flex-wrap:wrap;padding:10px 12px}
.cal-title{font-size:15px;order:-1;flex-basis:100%;padding-bottom:2px}
.cal-cell{min-height:48px;padding:4px 5px;gap:2px}
.cal-num{font-size:12px}
.cal-cnt{min-width:17px;height:17px;padding:0 4px;font-size:11px}
.cal-day-head{padding:12px}
.cal-row{flex-wrap:wrap;gap:6px 10px;padding:10px 12px}
.cal-row-time{width:auto;flex-basis:100%;flex-direction:row;align-items:baseline;gap:6px}
.cal-row-body{flex-basis:100%}
.cal-row-teacher{white-space:normal}
.cal-room{align-self:flex-start;max-width:100%}
.cal-row-dur{width:auto;margin-left:auto;align-self:center}
.search-results{max-height:280px}
.toast{left:14px;right:14px;bottom:80px}
}
@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="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>
</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="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" 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=["января","февраля","марта","апреля","мая","июня","июля","августа","сентября","октября","ноября","декабря"];
const MONTHS_NOM=["Январь","Февраль","Март","Апрель","Май","Июнь","Июль","Август","Сентябрь","Октябрь","Ноябрь","Декабрь"];
const WD_LEAD=["Пн","Вт","Ср","Чт","Пт","Сб","Вс"];
let ALL=[],PAGE="today",LOADING=true,ERROR=null,LAST_UPDATE="";
let CAL_MONTH=todayStr().slice(0,7),CAL_DAY=defaultDay();
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 defaultDay(){
const n=new Date();
if(isWeekend(n)){const m=new Date(n);m.setDate(n.getDate()-((n.getDay()+6)%7));return tds(m)}
return tds(n)
}
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();
if(PAGE==="week"){
document.getElementById("greeting").textContent="Календарь";
const mn=MONTHS[+CAL_MONTH.slice(5)-1];
const mc=ALL.filter(l=>l.date.slice(0,7)===CAL_MONTH).length;
document.getElementById("headerStatus").textContent=mc?mc+" "+plural(mc)+" · "+mn:"Нет расписания за "+mn;
return;
}
const heading=(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(){
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 dataRange(){
let mn=null,mx=null;
for(const l of ALL){if(!mn||l.date<mn)mn=l.date;if(!mx||l.date>mx)mx=l.date}
return[mn,mx]
}
function currentOf(ds){
if(ds!==todayStr())return null;
const nm=nowMin();
for(const l of lessonsOf(ds)){const s=pt(l.started_at),e=pt(l.finished_at);if(s<=nm&&nm<e)return l}
return null
}
function lessonPayload(l){
return 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;'));
}
function shiftMonth(n){
const[y,mi]=CAL_MONTH.split("-").map(Number);
const d=new Date(y,mi-1+n,1);
CAL_MONTH=d.getFullYear()+"-"+pd(d.getMonth()+1);
if(!CAL_DAY||CAL_DAY.slice(0,7)!==CAL_MONTH)CAL_DAY=CAL_MONTH+"-01";
render();
}
function calToday(){CAL_MONTH=todayStr().slice(0,7);CAL_DAY=defaultDay();render()}
function calPick(ds){CAL_DAY=ds;render()}
function openDayView(){
if(!CAL_DAY)return;
SELECTED_DAY=CAL_DAY;PAGE="today";
document.querySelectorAll(".nav-item,.mob-nav").forEach(b=>b.classList.toggle("active",b.dataset.page==="today"));
render();
}
function calAgenda(){
const ds=CAL_DAY;
const d=parseDate(ds);
const ls=lessonsOf(ds);
const[mn,mx]=dataRange();
const pending=!!(mx&&ds>mx);
const stale=!!(mn&&ds<mn);
const wk=isWeekend(d);
let h='<div class="cal-day"><div class="cal-day-head"><div>'
+'<div class="cal-day-name">'+dayOfWeekStr(d)+', '+dateStr(d)+'</div>'
+'<div class="cal-day-sub">';
if(ls.length)h+=ls.length+' '+plural2(ls.length)+' · первая в '+ls[0].started_at;
else if(pending)h+='Расписание ещё не опубликовано';
else if(stale)h+='Вне загруженного периода';
else if(wk)h+='Выходной';
else h+='Пар нет';
h+='</div></div>';
if(ds===todayStr())h+='<span class="tl-badge tl-live">сегодня</span>';
h+='</div>';
if(ls.length){
const cur=currentOf(ds);
h+='<div class="cal-agenda">';
ls.forEach(l=>{
const live=l===cur;
h+='<button class="cal-row'+(live?" current":"")+'" onclick="openLesson(\''+lessonPayload(l)+'\')">'
+'<span class="cal-row-time">'+l.started_at+'<span class="cal-row-end">'+l.finished_at+'</span></span>'
+'<span class="cal-row-body"><b class="cal-row-subj">'+esc(cleanSubject(l.subject_name))+'</b>'
+'<span class="cal-row-teacher">'+esc(l.teacher_name||"Преподаватель не указан")+'</span></span>'
+(l.room_name?'<span class="cal-room" title="'+esc(l.room_name)+'">'+esc(l.room_name)+'</span>':"")
+'<span class="cal-row-dur">'+fmtDur(Math.max(0,pt(l.finished_at)-pt(l.started_at)))+'</span>'
+(live?'<span class="tl-badge tl-live">идёт</span>':"")
+'</button>';
});
h+='</div>';
}else{
h+='<div class="cal-none">'+esc(pending?"Расписание на этот день ещё не появилось":stale?"Этот день вне загруженного расписания":wk?"Выходной — занятий нет":"На этот день пар нет")+'</div>';
}
return h+'<button class="cal-open" onclick="openDayView()">Открыть таймлайн дня</button></div>';
}
function renderCalendar(){
const[y,mi]=CAL_MONTH.split("-").map(Number);
const lead=(new Date(y,mi-1,1).getDay()+6)%7;
const days=new Date(y,mi,0).getDate();
const ts=todayStr();
const[mn,mx]=dataRange();
let h='<div class="cal">'
+'<div class="cal-head">'
+'<button class="cal-nav" onclick="shiftMonth(-1)" aria-label="Предыдущий месяц">&#8249;</button>'
+'<div class="cal-title">'+MONTHS_NOM[mi-1]+' <span class="cal-year">'+y+'</span></div>'
+'<button class="cal-nav" onclick="shiftMonth(1)" aria-label="Следующий месяц">&#8250;</button>'
+'<button class="cal-today" onclick="calToday()">К текущему месяцу</button>'
+'</div>'
+'<div class="cal-week">'+WD_LEAD.map(d=>'<span class="cal-wd">'+d+'</span>').join("")+'</div>'
+'<div class="cal-grid">';
for(let i=0;i<lead;i++)h+='<div class="cal-cell cal-pad"></div>';
for(let n=1;n<=days;n++){
const ds=y+"-"+pd(mi)+"-"+pd(n);
const cnt=lessonsOf(ds).length;
const wk=isWeekend(new Date(y,mi-1,n));
const blind=(mn&&ds<mn)||(mx&&ds>mx);
let cls="cal-cell";
if(blind)cls+=" blind";
else if(wk)cls+=" wknd";
if(ds===ts)cls+=" today";
if(ds===CAL_DAY)cls+=" selected";
h+='<button class="'+cls+'" onclick="calPick(\''+ds+'\')">'
+'<span class="cal-num">'+n+'</span>'
+(cnt?'<span class="cal-cnt">'+cnt+'</span>':"")
+'</button>';
}
return h+'</div>'+calAgenda()+'</div>';
}
function render(){
if(!SELECTED_DAY)SELECTED_DAY=defaultDay();
updateHeader();
let html="";
if(PAGE==="week")html=renderCalendar();
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(Date.UTC(now.getUTCFullYear(),now.getUTCMonth(),now.getUTCDate(),p[0],p[1],p[2]||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()