Фронт: без бейджа «Обе недели», недели на страницах преподов/аудиторий, иконка приложения
- пары с одинаковыми ч/з показываются одним списком без пометки недели (группы, преподаватели, аудитории); раньше на страницах преподавателей и аудиторий такое занятие дублировалось в обеих колонках - бейдж «Обе недели» убран как информационный шум (отсутствие пометки и означает «любая неделя») - favicon: квадрат с градиентом боковой панели (#1b2dd8 → #167cf2) и совой - PWA: manifest.json, sw.js, apple-touch-icon и иконки 192/512 — при добавлении на главный экран смартфона появляется логотип - tools/make_icons.py — генерация иконок из логотипа (rsvg-convert) - удалён неиспользуемый roomPeriodCell и мёртвый favicon.png
This commit is contained in:
18
README.md
18
README.md
@@ -72,6 +72,17 @@ nginx (https://schedule.our-place.ru) → 127.0.0.1:8090
|
|||||||
Итог по паре: `{time, ч: {raw, lessons[]}, з: {raw, lessons[]}}`, где
|
Итог по паре: `{time, ч: {raw, lessons[]}, з: {raw, lessons[]}}`, где
|
||||||
`lesson = {marker, subject, teacher, room, note}`.
|
`lesson = {marker, subject, teacher, room, note}`.
|
||||||
|
|
||||||
|
## Как отображается
|
||||||
|
|
||||||
|
- Если занятия числителя и знаменателя совпадают, пара показывается одним
|
||||||
|
списком без пометки недели (одинаково для групп, преподавателей и
|
||||||
|
аудиторий); иначе — две колонки «числитель»/«знаменатель», пустая
|
||||||
|
заменяется на «Окно».
|
||||||
|
- Подпись недели появляется только там, где недели действительно
|
||||||
|
различаются; бейдж «Сейчас» подсвечивает текущую неделю.
|
||||||
|
- Приложение можно «установить» на главный экран (манифест + service
|
||||||
|
worker + иконки приложения); на iOS иконку берёт `apple-touch-icon`.
|
||||||
|
|
||||||
## Структура кода
|
## Структура кода
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -90,7 +101,12 @@ src/
|
|||||||
main.py FastAPI: статика + прокси /api/* (таймаут 150 c)
|
main.py FastAPI: статика + прокси /api/* (таймаут 150 c)
|
||||||
static/
|
static/
|
||||||
index.html, app.js, style.css
|
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/
|
tests/
|
||||||
conftest.py общие фикстуры
|
conftest.py общие фикстуры
|
||||||
xlsx_builder.py генерация xlsx в памяти (без openpyxl)
|
xlsx_builder.py генерация xlsx в памяти (без openpyxl)
|
||||||
|
|||||||
@@ -227,7 +227,9 @@ function addPeriodRow(block, num, time, lessonsHTML) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function entityLessonHTML(parity, entry) {
|
function entityLessonHTML(parity, entry) {
|
||||||
const active = parity === currentParity ? " active" : "";
|
const badge = parity
|
||||||
|
? `<span class="${PARITY_CLASS[parity]}${parity === currentParity ? " active" : ""}">${PARITY_LABEL[parity]}</span>`
|
||||||
|
: "";
|
||||||
const details = [];
|
const details = [];
|
||||||
if (entry.room) details.push(entry.room);
|
if (entry.room) details.push(entry.room);
|
||||||
if (entry.teacher) details.push(entry.teacher);
|
if (entry.teacher) details.push(entry.teacher);
|
||||||
@@ -235,7 +237,7 @@ function entityLessonHTML(parity, entry) {
|
|||||||
return `
|
return `
|
||||||
<div class="lesson">
|
<div class="lesson">
|
||||||
<div>
|
<div>
|
||||||
<span class="${PARITY_CLASS[parity]}${active}">${PARITY_LABEL[parity]}</span>
|
${badge}
|
||||||
${entry.marker ? `<span class="badge badge-kv">${escapeHTML(entry.marker)}</span>` : ""}
|
${entry.marker ? `<span class="badge badge-kv">${escapeHTML(entry.marker)}</span>` : ""}
|
||||||
<span class="subject">${escapeHTML(entry.subject)}</span>
|
<span class="subject">${escapeHTML(entry.subject)}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -244,19 +246,36 @@ function entityLessonHTML(parity, entry) {
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Занятия числителя и знаменателя совпадают: неделя не важна. */
|
||||||
|
function sameEntries(chEntries, znEntries) {
|
||||||
|
return chEntries.length > 0 && JSON.stringify(chEntries) === JSON.stringify(znEntries);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Колонки занятий для страниц преподавателя и аудитории. */
|
||||||
|
function lessonsColHTML(chEntries, znEntries) {
|
||||||
|
if (sameEntries(chEntries, znEntries)) {
|
||||||
|
// не зависит от недели — одним списком, без бейджа недели
|
||||||
|
return `<div class="parities">${alwaysColHTML(chEntries.map((e) => entityLessonHTML(null, e)).join(""))}</div>`;
|
||||||
|
}
|
||||||
|
const chHtml = chEntries.map((e) => entityLessonHTML("ч", e)).join("");
|
||||||
|
const znHtml = znEntries.map((e) => entityLessonHTML("з", e)).join("");
|
||||||
|
return `<div class="parities">${parityColHTML("", chHtml || OKNO_HTML)}${parityColHTML("", znHtml || OKNO_HTML)}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
function singleLessonHTML(badgeKey, lesson, full) {
|
function singleLessonHTML(badgeKey, lesson, full) {
|
||||||
const config = {
|
const config = {
|
||||||
always: { label: "Обе недели", cls: "badge badge-always" },
|
always: { label: "", cls: "badge badge-always" },
|
||||||
ч: { label: "Числитель", cls: "badge badge-ch" },
|
ч: { label: "Числитель", cls: "badge badge-ch" },
|
||||||
з: { label: "Знаменатель", cls: "badge badge-zn" },
|
з: { label: "Знаменатель", cls: "badge badge-zn" },
|
||||||
};
|
};
|
||||||
const { label, cls } = config[badgeKey];
|
const { label, cls } = config[badgeKey];
|
||||||
const active = badgeKey !== "always" && badgeKey === currentParity ? " active" : "";
|
const active = badgeKey !== "always" && badgeKey === currentParity ? " active" : "";
|
||||||
|
const badge = label ? `<span class="${cls}${active}">${label}</span>` : "";
|
||||||
const details = [lesson.teacher, lesson.room].filter(Boolean).join(" · ");
|
const details = [lesson.teacher, lesson.room].filter(Boolean).join(" · ");
|
||||||
return `
|
return `
|
||||||
<div class="lesson${full ? " full" : ""}">
|
<div class="lesson${full ? " full" : ""}">
|
||||||
<div>
|
<div>
|
||||||
<span class="${cls}${active}">${label}</span>
|
${badge}
|
||||||
${lesson.marker ? `<span class="badge badge-kv">${escapeHTML(lesson.marker)}</span>` : ""}
|
${lesson.marker ? `<span class="badge badge-kv">${escapeHTML(lesson.marker)}</span>` : ""}
|
||||||
<span class="subject">${escapeHTML(lesson.subject)}</span>
|
<span class="subject">${escapeHTML(lesson.subject)}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -368,11 +387,11 @@ function renderTeacher(data, status) {
|
|||||||
Object.entries(periods)
|
Object.entries(periods)
|
||||||
.sort(([a], [b]) => Number(a) - Number(b))
|
.sort(([a], [b]) => Number(a) - Number(b))
|
||||||
.forEach(([num, slot]) => {
|
.forEach(([num, slot]) => {
|
||||||
const chHtml = (slot.ч || []).map((entry) => { hasAny = true; return entityLessonHTML("ч", entry); }).join("");
|
const chEntries = slot.ч || [];
|
||||||
const znHtml = (slot.з || []).map((entry) => { hasAny = true; return entityLessonHTML("з", entry); }).join("");
|
const znEntries = slot.з || [];
|
||||||
if (!chHtml && !znHtml) return;
|
if (!chEntries.length && !znEntries.length) return;
|
||||||
const inner = `<div class="parities">${parityColHTML("Числитель", chHtml || OKNO_HTML)}${parityColHTML("Знаменатель", znHtml || OKNO_HTML)}</div>`;
|
hasAny = true;
|
||||||
addPeriodRow(block, num, slot.time, inner);
|
addPeriodRow(block, num, slot.time, lessonsColHTML(chEntries, znEntries));
|
||||||
});
|
});
|
||||||
daysContainer.appendChild(block);
|
daysContainer.appendChild(block);
|
||||||
});
|
});
|
||||||
@@ -457,9 +476,7 @@ function buildRoomsList(data, periodNums) {
|
|||||||
if (!chEntries.length && !znEntries.length) {
|
if (!chEntries.length && !znEntries.length) {
|
||||||
body.innerHTML = `<div class="parities">${parityColHTML("", OKNO_HTML)}</div>`;
|
body.innerHTML = `<div class="parities">${parityColHTML("", OKNO_HTML)}</div>`;
|
||||||
} else {
|
} else {
|
||||||
const chHtml = chEntries.map((e) => entityLessonHTML("ч", e)).join("");
|
body.innerHTML = lessonsColHTML(chEntries, znEntries);
|
||||||
const znHtml = znEntries.map((e) => entityLessonHTML("з", e)).join("");
|
|
||||||
body.innerHTML = `<div class="parities">${parityColHTML("", chHtml || OKNO_HTML)}${parityColHTML("", znHtml || OKNO_HTML)}</div>`;
|
|
||||||
}
|
}
|
||||||
row.appendChild(body);
|
row.appendChild(body);
|
||||||
card.appendChild(row);
|
card.appendChild(row);
|
||||||
@@ -469,33 +486,6 @@ function buildRoomsList(data, periodNums) {
|
|||||||
return list;
|
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) {
|
function escapeHTML(s) {
|
||||||
return s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
return s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
||||||
}
|
}
|
||||||
@@ -547,3 +537,10 @@ loadWeek().finally(() => {
|
|||||||
errorEl.classList.remove("hidden");
|
errorEl.classList.remove("hidden");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// PWA: ярлык «на главный экран» с иконкой приложения (манифест + service worker)
|
||||||
|
if ("serviceWorker" in navigator) {
|
||||||
|
window.addEventListener("load", () => {
|
||||||
|
navigator.serviceWorker.register("/sw.js").catch(() => {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
36
src/frontend/static/img/app-icon.svg
Normal file
36
src/frontend/static/img/app-icon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 35 KiB |
BIN
src/frontend/static/img/apple-touch-icon.png
Normal file
BIN
src/frontend/static/img/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.6 KiB |
BIN
src/frontend/static/img/favicon-32.png
Normal file
BIN
src/frontend/static/img/favicon-32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 905 B |
Binary file not shown.
|
Before Width: | Height: | Size: 315 B |
BIN
src/frontend/static/img/icon-192.png
Normal file
BIN
src/frontend/static/img/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
BIN
src/frontend/static/img/icon-512.png
Normal file
BIN
src/frontend/static/img/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
@@ -4,7 +4,16 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Расписание · Факультет ИВТ ЯрГУ</title>
|
<title>Расписание · Факультет ИВТ ЯрГУ</title>
|
||||||
<link rel="icon" type="image/svg+xml" href="/img/owl-white.svg">
|
<meta name="description" content="Расписание занятий факультета информатики и вычислительной техники ЯрГУ">
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/img/app-icon.svg">
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="/img/favicon-32.png">
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="/img/apple-touch-icon.png">
|
||||||
|
<link rel="manifest" href="/manifest.json">
|
||||||
|
<meta name="theme-color" content="#1b2dd8">
|
||||||
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-title" content="Расписание">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
||||||
<link rel="stylesheet" href="/style.css">
|
<link rel="stylesheet" href="/style.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
16
src/frontend/static/manifest.json
Normal file
16
src/frontend/static/manifest.json
Normal file
@@ -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" }
|
||||||
|
]
|
||||||
|
}
|
||||||
8
src/frontend/static/sw.js
Normal file
8
src/frontend/static/sw.js
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
// Минимальный service worker: браузеры требуют его наличия, чтобы предложить
|
||||||
|
// «установить приложение» и показать иконку на главном экране.
|
||||||
|
// Ничего не кэширует — источник данных всегда сеть.
|
||||||
|
self.addEventListener("install", () => self.skipWaiting());
|
||||||
|
self.addEventListener("activate", (event) => event.waitUntil(self.clients.claim()));
|
||||||
|
self.addEventListener("fetch", () => {
|
||||||
|
// пустой обработчик: запросы уходят в сеть как обычно
|
||||||
|
});
|
||||||
121
tools/make_icons.py
Normal file
121
tools/make_icons.py
Normal file
@@ -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 '<?xml version="1.0" encoding="UTF-8"?>\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())
|
||||||
Reference in New Issue
Block a user