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 `
${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}
+ ${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())