diff --git a/README.md b/README.md index 16feb20..6c06888 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,17 @@ nginx (https://schedule.our-place.ru) → 127.0.0.1:8090 Итог по паре: `{time, ч: {raw, lessons[]}, з: {raw, lessons[]}}`, где `lesson = {marker, subject, teacher, room, note}`. +## Как отображается + +- Если занятия числителя и знаменателя совпадают, пара показывается одним + списком без пометки недели (одинаково для групп, преподавателей и + аудиторий); иначе — две колонки «числитель»/«знаменатель», пустая + заменяется на «Окно». +- Подпись недели появляется только там, где недели действительно + различаются; бейдж «Сейчас» подсвечивает текущую неделю. +- Приложение можно «установить» на главный экран (манифест + service + worker + иконки приложения); на iOS иконку берёт `apple-touch-icon`. + ## Структура кода ``` @@ -90,7 +101,12 @@ src/ main.py FastAPI: статика + прокси /api/* (таймаут 150 c) static/ index.html, app.js, style.css - img/ логотипы (owl.svg, owl-white.svg), favicon.png + manifest.json PWA: имя, иконки, standalone-режим + sw.js минимальный service worker (без кэша) для установки приложения + img/ логотипы (owl.svg, owl-white.svg), иконки приложения + (app-icon.svg, icon-192/512.png, apple-touch-icon.png, favicon-32.png) +tools/ + make_icons.py генерация иконок приложения из логотипа (нужен rsvg-convert) tests/ conftest.py общие фикстуры xlsx_builder.py генерация xlsx в памяти (без openpyxl) diff --git a/src/frontend/static/app.js b/src/frontend/static/app.js index 273ea19..215e903 100644 --- a/src/frontend/static/app.js +++ b/src/frontend/static/app.js @@ -227,7 +227,9 @@ function addPeriodRow(block, num, time, lessonsHTML) { } function entityLessonHTML(parity, entry) { - const active = parity === currentParity ? " active" : ""; + const badge = parity + ? `${PARITY_LABEL[parity]}` + : ""; const details = []; if (entry.room) details.push(entry.room); if (entry.teacher) details.push(entry.teacher); @@ -235,7 +237,7 @@ function entityLessonHTML(parity, entry) { return `
- ${PARITY_LABEL[parity]} + ${badge} ${entry.marker ? `${escapeHTML(entry.marker)}` : ""} ${escapeHTML(entry.subject)}
@@ -244,19 +246,36 @@ function entityLessonHTML(parity, entry) {
`; } +/** Занятия числителя и знаменателя совпадают: неделя не важна. */ +function sameEntries(chEntries, znEntries) { + return chEntries.length > 0 && JSON.stringify(chEntries) === JSON.stringify(znEntries); +} + +/** Колонки занятий для страниц преподавателя и аудитории. */ +function lessonsColHTML(chEntries, znEntries) { + if (sameEntries(chEntries, znEntries)) { + // не зависит от недели — одним списком, без бейджа недели + return `
${alwaysColHTML(chEntries.map((e) => entityLessonHTML(null, e)).join(""))}
`; + } + const chHtml = chEntries.map((e) => entityLessonHTML("ч", e)).join(""); + const znHtml = znEntries.map((e) => entityLessonHTML("з", e)).join(""); + return `
${parityColHTML("", chHtml || OKNO_HTML)}${parityColHTML("", znHtml || OKNO_HTML)}
`; +} + function singleLessonHTML(badgeKey, lesson, full) { const config = { - always: { label: "Обе недели", cls: "badge badge-always" }, + always: { label: "", cls: "badge badge-always" }, ч: { label: "Числитель", cls: "badge badge-ch" }, з: { label: "Знаменатель", cls: "badge badge-zn" }, }; const { label, cls } = config[badgeKey]; const active = badgeKey !== "always" && badgeKey === currentParity ? " active" : ""; + const badge = label ? `${label}` : ""; const details = [lesson.teacher, lesson.room].filter(Boolean).join(" · "); return `
- ${label} + ${badge} ${lesson.marker ? `${escapeHTML(lesson.marker)}` : ""} ${escapeHTML(lesson.subject)}
@@ -368,11 +387,11 @@ function renderTeacher(data, status) { Object.entries(periods) .sort(([a], [b]) => Number(a) - Number(b)) .forEach(([num, slot]) => { - const chHtml = (slot.ч || []).map((entry) => { hasAny = true; return entityLessonHTML("ч", entry); }).join(""); - const znHtml = (slot.з || []).map((entry) => { hasAny = true; return entityLessonHTML("з", entry); }).join(""); - if (!chHtml && !znHtml) return; - const inner = `
${parityColHTML("Числитель", chHtml || OKNO_HTML)}${parityColHTML("Знаменатель", znHtml || OKNO_HTML)}
`; - addPeriodRow(block, num, slot.time, inner); + const chEntries = slot.ч || []; + const znEntries = slot.з || []; + if (!chEntries.length && !znEntries.length) return; + hasAny = true; + addPeriodRow(block, num, slot.time, lessonsColHTML(chEntries, znEntries)); }); daysContainer.appendChild(block); }); @@ -457,9 +476,7 @@ function buildRoomsList(data, periodNums) { if (!chEntries.length && !znEntries.length) { body.innerHTML = `
${parityColHTML("", OKNO_HTML)}
`; } else { - const chHtml = chEntries.map((e) => entityLessonHTML("ч", e)).join(""); - const znHtml = znEntries.map((e) => entityLessonHTML("з", e)).join(""); - body.innerHTML = `
${parityColHTML("", chHtml || OKNO_HTML)}${parityColHTML("", znHtml || OKNO_HTML)}
`; + body.innerHTML = lessonsColHTML(chEntries, znEntries); } row.appendChild(body); card.appendChild(row); @@ -469,33 +486,6 @@ function buildRoomsList(data, periodNums) { return list; } -function roomPeriodCell(slot) { - const td = document.createElement("td"); - td.className = "r-cell"; - const all = [...((slot && slot.ч) || []), ...((slot && slot.з) || [])]; - if (!all.length) { - td.classList.add("r-free"); - td.textContent = "окно"; - return td; - } - td.classList.add("r-busy"); - const seen = new Set(); - const lines = []; - all.forEach((e) => { - if (!e.subject || seen.has(e.subject)) return; - seen.add(e.subject); - lines.push(e.subject); - }); - lines.forEach((x) => { - const div = document.createElement("div"); - div.className = "r-subj"; - div.textContent = x; - td.appendChild(div); - }); - td.title = lines.join("; "); - return td; -} - function escapeHTML(s) { return s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); } @@ -547,3 +537,10 @@ loadWeek().finally(() => { errorEl.classList.remove("hidden"); }); }); + +// PWA: ярлык «на главный экран» с иконкой приложения (манифест + service worker) +if ("serviceWorker" in navigator) { + window.addEventListener("load", () => { + navigator.serviceWorker.register("/sw.js").catch(() => {}); + }); +} diff --git a/src/frontend/static/img/app-icon.svg b/src/frontend/static/img/app-icon.svg new file mode 100644 index 0000000..1550027 --- /dev/null +++ b/src/frontend/static/img/app-icon.svg @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/frontend/static/img/apple-touch-icon.png b/src/frontend/static/img/apple-touch-icon.png new file mode 100644 index 0000000..49bd074 Binary files /dev/null and b/src/frontend/static/img/apple-touch-icon.png differ diff --git a/src/frontend/static/img/favicon-32.png b/src/frontend/static/img/favicon-32.png new file mode 100644 index 0000000..76ab8f8 Binary files /dev/null and b/src/frontend/static/img/favicon-32.png differ diff --git a/src/frontend/static/img/favicon.png b/src/frontend/static/img/favicon.png deleted file mode 100644 index bf71d6c..0000000 Binary files a/src/frontend/static/img/favicon.png and /dev/null differ diff --git a/src/frontend/static/img/icon-192.png b/src/frontend/static/img/icon-192.png new file mode 100644 index 0000000..7c9752a Binary files /dev/null and b/src/frontend/static/img/icon-192.png differ diff --git a/src/frontend/static/img/icon-512.png b/src/frontend/static/img/icon-512.png new file mode 100644 index 0000000..8dffe19 Binary files /dev/null and b/src/frontend/static/img/icon-512.png differ diff --git a/src/frontend/static/index.html b/src/frontend/static/index.html index dd32b27..9d0eafb 100644 --- a/src/frontend/static/index.html +++ b/src/frontend/static/index.html @@ -4,7 +4,16 @@ Расписание · Факультет ИВТ ЯрГУ - + + + + + + + + + + diff --git a/src/frontend/static/manifest.json b/src/frontend/static/manifest.json new file mode 100644 index 0000000..088987f --- /dev/null +++ b/src/frontend/static/manifest.json @@ -0,0 +1,16 @@ +{ + "name": "Расписание · Факультет ИВТ ЯрГУ", + "short_name": "Расписание", + "description": "Расписание занятий факультета информатики и вычислительной техники ЯрГУ", + "lang": "ru", + "start_url": "/", + "scope": "/", + "display": "standalone", + "background_color": "#f1f5f9", + "theme_color": "#1b2dd8", + "icons": [ + { "src": "/img/icon-192.png", "sizes": "192x192", "type": "image/png" }, + { "src": "/img/icon-512.png", "sizes": "512x512", "type": "image/png" }, + { "src": "/img/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" } + ] +} diff --git a/src/frontend/static/sw.js b/src/frontend/static/sw.js new file mode 100644 index 0000000..9d9b67c --- /dev/null +++ b/src/frontend/static/sw.js @@ -0,0 +1,8 @@ +// Минимальный service worker: браузеры требуют его наличия, чтобы предложить +// «установить приложение» и показать иконку на главном экране. +// Ничего не кэширует — источник данных всегда сеть. +self.addEventListener("install", () => self.skipWaiting()); +self.addEventListener("activate", (event) => event.waitUntil(self.clients.claim())); +self.addEventListener("fetch", () => { + // пустой обработчик: запросы уходят в сеть как обычно +}); diff --git a/tools/make_icons.py b/tools/make_icons.py new file mode 100644 index 0000000..bfc82a0 --- /dev/null +++ b/tools/make_icons.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +"""Генерация иконок приложения (фавиконка + PWA) из логотипа факультета. + +Квадрат с градиентом боковой панели сайта (#1b2dd8 → #167cf2), внутрь +помещается белая сова. Результат: + img/app-icon.svg — самодостаточный svg (favicon) + img/icon-192.png — иконка манифеста + img/icon-512.png — иконка манифеста (maskable) + img/apple-touch-icon.png — 180x180 для iOS + img/favicon-32.png — растровая запасная фавиконка + +Запуск (нужен rsvg-convert из librsvg2-bin): + python3 tools/make_icons.py +""" + +from __future__ import annotations + +import subprocess +import sys +import xml.etree.ElementTree as ET +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +IMG = ROOT / "src" / "frontend" / "static" / "img" + +SVG_NS = "http://www.w3.org/2000/svg" +SODIPODI_NS = "http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" +INKSCAPE_NS = "http://www.inkscape.org/namespaces/inkscape" + +SIZE = 512 +GRADIENT = (("#1b2dd8", "0%"), ("#167cf2", "100%")) # как .side в светлой теме +OWL_SCALE = 0.62 # доля стороны иконки под логотип + + +def strip_ns(el: ET.Element) -> None: + """Убирает inkscape/sodipodi-атрибуты (в чужом документе они не нужны).""" + for key in list(el.attrib): + if key.startswith(f"{{{SODIPODI_NS}}}") or key.startswith(f"{{{INKSCAPE_NS}}}"): + del el.attrib[key] + for child in el: + strip_ns(child) + + +def build_icon_svg() -> str: + owl_root = ET.parse(IMG / "owl-white.svg").getroot() + view_box = owl_root.get("viewBox", "0 0 109.34 109.87") + _, _, ow, oh = (float(v) for v in view_box.replace(",", " ").split()) + + # размеры и положение логотипа внутри квадрата + target = SIZE * OWL_SCALE + scale = target / max(ow, oh) + tx = (SIZE - ow * scale) / 2 + ty = (SIZE - oh * scale) / 2 + + ET.register_namespace("", SVG_NS) + icon = ET.Element( + f"{{{SVG_NS}}}svg", + { + "viewBox": f"0 0 {SIZE} {SIZE}", + "width": str(SIZE), + "height": str(SIZE), + }, + ) + defs = ET.SubElement(icon, f"{{{SVG_NS}}}defs") + grad = ET.SubElement( + defs, + f"{{{SVG_NS}}}linearGradient", + {"id": "bg", "x1": "0", "y1": "0", "x2": "0", "y2": "1"}, + ) + for color, offset in GRADIENT: + ET.SubElement( + grad, f"{{{SVG_NS}}}stop", {"offset": offset, "stop-color": color} + ) + ET.SubElement( + icon, f"{{{SVG_NS}}}rect", {"width": str(SIZE), "height": str(SIZE), "fill": "url(#bg)"} + ) + + group = ET.SubElement( + icon, + f"{{{SVG_NS}}}g", + {"transform": f"translate({tx:.2f} {ty:.2f}) scale({scale:.6f})"}, + ) + for child in owl_root: + tag = child.tag + if tag.endswith("namedview") or tag.endswith("metadata"): + continue + clone = ET.fromstring(ET.tostring(child)) + strip_ns(clone) + group.append(clone) + + xml = ET.tostring(icon, encoding="unicode") + return '\n' + xml + "\n" + + +def rasterize(svg_path: Path, size: int, out_path: Path) -> None: + subprocess.run( + ["rsvg-convert", "-w", str(size), "-h", str(size), "-o", str(out_path), str(svg_path)], + check=True, + ) + + +def main() -> int: + svg = build_icon_svg() + icon = IMG / "app-icon.svg" + icon.write_text(svg, encoding="utf-8") + + if not subprocess.run(["which", "rsvg-convert"], capture_output=True).stdout: + print("нет rsvg-convert — svg создан, png не сгенерированы", file=sys.stderr) + return 1 + + rasterize(icon, 192, IMG / "icon-192.png") + rasterize(icon, 512, IMG / "icon-512.png") + rasterize(icon, 180, IMG / "apple-touch-icon.png") + rasterize(icon, 32, IMG / "favicon-32.png") + for name in ("app-icon.svg", "icon-192.png", "icon-512.png", "apple-touch-icon.png", "favicon-32.png"): + print("готово:", (IMG / name).relative_to(ROOT)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())