- Implemented SettingsScreen for user preferences including theme selection, reminders, and notifications. - Created SubjectsScreen to display subjects with upcoming lessons and associated details. - Introduced ThemeReveal for a smooth transition between light and dark themes. - Added support for managing notes and tasks related to subjects. - Enhanced UI components for better user experience and accessibility.
101 KiB
Расписание ПГУ
Первый рабочий PWA-клиент для расписаний Полоцкого государственного университета. При первом запуске предлагает найти свою группу; исходный сценарий проверки — ФЭФ, 23-БА из общего документа 23-ЛГ_23-БА.docx за 2026–2027 учебный год.
Запуск
Нужны Node.js 22.12+ (проверено на 24.14.1) и npm.
npm ci
npm run build
npm start
Откройте http://127.0.0.1:3001. Этот режим включает service worker, установку PWA и офлайн-кэш. Для разработки с обновлением интерфейса:
npm run dev
Vite работает на http://127.0.0.1:5173 и проксирует /api на порт 3001 (другой порт — через API_PORT). В режиме разработки service worker не регистрируется. PORT и HOST настраивают сервер; при размещении нужен HTTPS. Для доступа с телефона используйте HTTPS-хостинг либо локальный HTTPS-туннель.
Возможности
- Живой каталог публичного Nextcloud университета: факультет, форма, курс и поиск по названию группы/файла. Проверенный каталог содержит 113 документов Word.
- Расписание по дням или на всю неделю, поиск предмета, преподавателя и аудитории.
- Экран «Группы»: поиск группы по номеру в любом написании («23-БА», «23 ба», «23ба») по всем документам каталога, без учёта фильтров; одно нажатие открывает нужный документ и группу. Ниже — текущая группа с подгруппой и соседними группами документа, недавние группы (открываются и без сети) и каталог чипами: факультет → курс → форма обучения. Первый запуск начинается с этого экрана, а не с чьей-то группы (
/api/groups). Если у группы есть основное расписание, в выборе не показываются её расписание зачётов и экзаменационной сессии (они идут в обратный отсчёт сессии) и уже прошедшие документы с датами, например неделя «Введение в высшее образование». Группе, у которой есть только такой документ, он остаётся доступен. - Общие ячейки попадают в каждую соответствующую группу, чужие занятия отсекаются.
- Отдельные карточки для альтернативных занятий и условий по неделям. Сохраняются исходный текст, цвет ячейки, подгруппы, даты и ограничения.
- Учебные недели по таблице университета: переключатель недель с датами, показ только занятий выбранной недели, статус занятия («ещё 4 занятия · до 27 нояб.», «начнётся 20 нояб.») и сетка недель в деталях. Режим «Все строки Word» показывает документ как есть.
- Номера пар и текущая пара («Идёт · ещё 25 мин») по расписанию звонков.
- Занятия в другом городе. Если в Word над днём написано «Занятия в Полоцке», над парами этого дня — в виде «День» и «Неделя» — стоит карточка «Занятия в Полоцке · пометка в Word на этот день» (с подгруппой, если заметка только её). В карточке пары город стоит рядом с аудиторией («Ауд. 249Ж · Полоцк»), в деталях — строка «Город». Сохранённые раньше расписания перечитываются по версии парсера и получают города сами.
- «Что изменилось»: когда университет перепубликует документ, приложение сравнивает новую версию с сохранённой и показывает для вашей группы изменённые поля (аудитория, время, преподаватель, недели), добавленные и удалённые занятия. Последний список изменений открывается из меню.
- Моя подгруппа и скрытые занятия: в «Группах» можно выбрать подгруппу — занятия другой подгруппы пропадут; любое занятие можно скрыть из его карточки и вернуть кнопкой «Скрыто · показать». Настройки хранятся только в этом браузере.
- Поиск по университету (вкладка «Поиск»): преподаватель или аудитория — занятия на выбранный день по всем расписаниям каталога; свободные аудитории на дату и пару. Сервер разбирает все документы каталога в фоне (первый раз около 20–30 секунд, дальше из кэша) и считает занятость с учётом недель, цвета строк, семестра и разовых переносов аудиторий вида «432 (07.10-ауд.272)». Дистанционные занятия («200 (ДО)») аудиторию не занимают. Каждый ответ сообщает, сколько документов учтено; «свободна» означает «свободна по расписаниям из Word», мероприятия и закрытые аудитории расписание не знает. Работает только онлайн.
- Сессия: экзамены, зачёты и консультации группы из всех документов каталога (распознаются по «(экз.)», «(зач.)», «(конс.)» и словам «Экзамен», «Зачёт», «Консультация»). На главном экране — карточка с обратным отсчётом до ближайшего экзамена или зачёта, полный список — в листе «Сессия». Список сохраняется для просмотра без сети. В текущем каталоге документов экзаменационной сессии ещё нет; формат проверен на образцах, а не на реальных файлах университета.
- Ссылка и QR-код на расписание группы (
/?doc=<id>&group=<группа>) из меню «Поделиться расписанием». Ссылка открывает документ из каталога и нужную группу; подгруппа и скрытые занятия не передаются. Устаревшая ссылка (документ убран из каталога) открывает поиск группы. Для импортированных файлов ссылка недоступна. - Фильтр белых/зелёных строк. Объединённое на обе строки занятие показывается в обоих вариантах без дублирования.
- Импорт
.docxи.docдо 10 МБ с предварительным просмотром перед сохранением. - Сохранение последних 12 документов и выбранной группы в этом браузере. После первой успешной загрузки собранной PWA интерфейс, шрифты и расписание доступны без сервера/сети.
- Манифест PWA, PNG-иконки, установка на главный экран и ссылка на исходный Word.
Источник и обновления
Каталог университета читается через публичный DAV. Сервер обращается только к этой общей папке. Другие ссылки не принимаются.
Учебный год выбирается сам. Сервер читает корень каталога, находит папки вида 2026-2027 и с 1 сентября (Europe/Minsk) берёт папку нового года. Если её ещё нет, используется самая новая из прошлых, а дашборд /status предупреждает об этом. Таблица белых и зелёных недель и расписание звонков ищутся по смыслу имени («бел» + «зел» + «недел», «звонк»), сначала в папке года, затем в корне. Можно выложить несколько таблиц недель: они объединяются, при совпадении дат побеждает более новый файл. В список расписаний эти справочные файлы не попадают.
Семестры. Таблица делится на семестры там, где нумерация недель начинается заново или между неделями больше трёх недель. Расписание связывается со своим семестром по тексту Word («осенний семестр 2026/2027»), расписание сессии — по своим датам, документ без указания семестра — по семестру, в котором сегодняшняя дата. Если семестр не найден (например, весенней таблицы ещё нет), даты не рассчитываются и показываются все строки Word.
Обновления у студента. При запуске и при возвращении в приложение, если прошло больше часа, клиент сверяет сохранённое расписание с каталогом. Тот же файл с новой датой тихо перечитывается. Так же один раз перечитывается расписание, сохранённое более старым парсером, чем на сервере: /api/catalog сообщает parserVersion, и PWA сравнивает его с версией сохранённой копии. Поэтому исправления разбора доходят до студентов без перепубликации документа. У сервера без parserVersion перечитывания нет. Если для группы появился более новый документ (следующий семестр или год), показывается предложение его открыть; отказ запоминается. Если документ исчез из каталога, приложение предлагает найти группу заново.
Проверка каталога по ETag. Сервер сам проверяет каталог университета раз в 5 минут, а не только когда приходит запрос. У каждой папки Nextcloud есть ETag, и он меняется, когда меняется что‑то внутри. Сервер хранит листинги прошлого обхода (.data/catalog-tree.json) и заново запрашивает только папки с новым ETag. Проверка неизменного каталога — 2 запроса вместо ~100: корень и папка года. Перезалитый документ стоит ещё пары запросов по пути к нему. Раз в 6 часов на всякий случай идёт полный обход. Индекс пересобирается, только если изменился какой‑то документ, поэтому ETag расписаний и кэши клиентов между проверками не сбрасываются. На дашборде (поле catalog.lastCrawl) видно последний обход: число запросов, полный ли он, сколько папок взято из кэша. Поле watch там же — время и ошибка последней проверки.
Вежливость к университету. Каждый запрос к Nextcloud представляется: User-Agent: PSU-Schedule/0.1.0 (+<PSU_PUBLIC_URL>; <PSU_CONTACT>). Например, PSU_CONTACT=mailto:you@example.com — тогда администраторы университета видят, чей это сервис и кому написать, а не безымянного робота. Когда университет перестаёт отвечать (нет соединения, 502–504, 429, долго не отвечает листинг папки), сервер делает паузу: до её конца к университету не уходит ни один запрос — ни от наблюдателя, ни от «Обновить» в клиентах, ни при открытии документа. Студенты получают сохранённую копию. Пауза удваивается после каждой неудачной попытки: 5, 10, 20, 40 минут, дальше раз в час (PSU_SOURCE_BACKOFF_MS, PSU_SOURCE_BACKOFF_MAX_MS), и учитывает Retry-After. Первый же ответ её снимает. Скачанные документы сервер хранит у себя (.data/documents/), поэтому новая версия парсера перечитывает их локально, не обращаясь к университету. Ошибка одного документа (404, 500) и медленная загрузка файла паузу не включают. На дашборде у плитки «Университет» видно время следующей попытки, в /status/data.json — catalog.backoff.
Дашборд /status. Страница состояния сервера за паролем DASHBOARD_PASSWORD. Вход через обычное окно браузера, имя пользователя любое. Без пароля дашборд выключен и отвечает 404. После десяти неверных паролей с одного адреса вход блокируется на 10 минут. Страница не кэшируется и закрыта от поисковиков (noindex). На ней:
- плитки состояния — сервер, каталог (год, число документов, последняя проверка и её стоимость в запросах), университет (отвечает ли, с какого времени не отвечает), разбор (подозрительные ячейки, нечитаемые документы), Telegram;
- запросы за последний час — ключевые числа, график по минутам (с ответом,
304, ошибки), адреса API со средним временем и самые активные клиенты; - лента событий — новые, перезалитые и нечитаемые документы, смена учебного года, сбои университета; последние 100 событий хранятся в
.data/events.json; - подозрительные ячейки — по документам, с исходным текстом ячейки и ссылкой на оригинал, с фильтрами по виду и поиском по группе, документу или тексту.
Страница — обычные HTML, CSS и JS из server/status/ без сборки, под той же строгой CSP. Она обновляется раз в 30 секунд, пока вкладка открыта. Данные берёт из /status/data.json за тем же паролем. Публичный /api/health после появления дашборда отвечает только «жив, готов, версии, учебный год»: остальное называет документы, клиентов и настройки владельца.
Журнал запросов и метрики. На каждый запрос к /api в журнал (docker logs) пишется одна строка JSON: время, метод, путь с параметрами, статус, время ответа в мс, код ошибки, размер ответа и адрес клиента без последней части (203.0.113.0, у IPv6 — /48). Точные адреса не сохраняются. Проверки здоровья (/api/health, /api/ready) не пишутся и не считаются. Журнал выключается переменной PSU_REQUEST_LOG=off.
Поле traffic на дашборде (/status/data.json) — сводка за последний час (lastHour) и с запуска (total):
- сколько было запросов и какая доля ушла пустым
304; - ошибки клиента и сервера;
- срабатывания лимита (
limited) и таймаута (timeouts); - ответы
502/504из‑за университета (sourceErrors) и упавшие запросы к нему (sourceFailures); - медленные ответы, дольше секунды;
- медиана и 95‑й процентиль времени ответа — как граница корзины: 25, 50, 100, 250… мс;
- десять самых частых адресов API со средним временем.
За последний час есть ещё число разных клиентов и три самых активных. Так видно, если кто‑то перебирает API.
Оповещения в Telegram. Если заданы TELEGRAM_BOT_TOKEN и TELEGRAM_CHAT_ID, сервер пишет владельцу после проверок каталога:
- появился новый документ — с его группами и подозрительными ячейками;
- документ не читается;
- документ перезалит, и в нём появились подозрительные ячейки, которых раньше не было;
- каталог перешёл на новый учебный год, или папка нового года задерживается;
- университет не отвечает дольше часа, а потом снова отвечает.
Первая проверка только сообщает, что оповещения включены, и называет число документов и подозрительных ячеек. Если сообщение не ушло, оно повторится при следующей проверке. С PSU_PUBLIC_URL в сообщениях есть ссылка на /api/diagnostics документа.
Настройка:
- Создайте бота у @BotFather и получите токен.
- Напишите боту любое сообщение.
- Возьмите
chat.idиз ответаhttps://api.telegram.org/bot<токен>/getUpdates. - Положите значения в
.envрядом сdocker-compose.yml(файл в.gitignore):
TELEGRAM_BOT_TOKEN=123456:ABC...
TELEGRAM_CHAT_ID=123456789
PSU_PUBLIC_URL=https://psu.rumblur.by
- Перезапустите контейнер:
docker compose up -d.
Состояние оповещений — плитка Telegram на дашборде и поле telegram в /status/data.json: включены ли, сколько отправлено и последняя ошибка. Токен в журналы и в /api/health не попадает.
Каталог перечитывается раз в 5 минут (по ETag), папка года — раз в 15 минут, таблица недель и звонки — раз в 6 часов, распознанный документ — при изменении даты файла или версии парсера. Устаревшая копия отвечает сразу, а перечитывание идёт в фоне. Ждать приходится только при первом запуске, в начале нового учебного года и при «Обновить». «Обновить» (?refresh=1) принудительно перечитывает всё, но обращается к университету не чаще раза в 2 минуты (таблицу недель — раз в 10 минут), иначе отвечает только что прочитанной копией. После сбоя источника фоновые попытки тоже ждут 2 минуты. Если источник недоступен, используются сохранённые копии с явной пометкой. Фоновая синхронизация при закрытом приложении не реализована.
Ответы API сжимаются (gzip или brotli; семестр группы — около 200 КБ JSON, в gzip — около 5 КБ) и отдаются с Cache-Control: no-cache и ETag. Клиент хранит ответ, но каждый раз спрашивает заново. Если ничего не изменилось, приходит пустой 304. Android-клиент делает это сам через дисковый кэш OkHttp. Импорт Word ограничен: два разбора одновременно, ещё четыре ждут, остальным — 429; с одного адреса — до 10 файлов за 10 минут. За прокси (Caddy, туннель) адрес клиента берётся из X-Forwarded-For, если прокси находится в локальной или частной сети. Для /api/timetable ETag считается до любой работы — из версии документа, парсера и сервера, таблицы недель, параметров запроса и даты, — поэтому неизменённое расписание отвечает 304 примерно за миллисекунду, не открывая документ.
Ошибки приходят как { "error": "текст для показа", "code": "…" }. Клиенты опираются на code, а не на текст: bad_request (400), unknown_endpoint, document_not_found, group_not_found (404), file_too_large (413), unreadable_file (422), too_many_requests и busy (429), source_unavailable и source_invalid (502, каталог университета не ответил или не распознан), source_timeout и timeout (504, университет или сам сервер не ответили вовремя), internal (500). Полный список — в server/errors.js. Android и iOS при source_unavailable, source_timeout, timeout, busy и too_many_requests оставляют на экране сохранённую копию и показывают «Повторить».
К каталогу университета сервер обращается встроенным в Node fetch: соединения переиспользуются, сертификаты проверяются, перенаправления не принимаются, ответ ограничен 10 МБ и 25 секундами. Обход каталога (около сотни папок) занимает несколько секунд.
Защита открытого сервера.
- Лимит запросов. К
/api— не больше 300 запросов в минуту с одного адреса, сверх лимита ответ429 too_many_requestsсRetry-After. Лимит щедрый, потому что студенты в общежитии или в сети университета выходят в интернет с одного адреса: он ловит только ботов и зациклившиеся клиенты. Проверки здоровья (/api/health,/api/ready) не считаются. Импорт Word ограничен отдельно: 10 файлов за 10 минут. - Время ответа. Запрос, на который сервер не ответил за 30 секунд (импорт — за 90), получает
504 timeout. Клиент должен прислать заголовки за 15 секунд, а весь запрос — за две минуты. - Заголовки безопасности. Все ответы несут строгую
Content-Security-Policy: только свои скрипты, стили, шрифты и запросы, а единственный inline-скрипт (тема до первой отрисовки) разрешён по хэшу, который сервер считает из собранногоdist/index.html. Кроме неё:X-Content-Type-Options,X-Frame-Options: DENY,Referrer-Policy,Permissions-Policy,Cross-Origin-Opener-Policy. За HTTPS-прокси, который передаётX-Forwarded-Proto, добавляетсяStrict-Transport-Security. - Адрес клиента за прокси. Лимит считается по настоящему адресу клиента. Сервер верит
X-Forwarded-Forтолько от прокси из локальной сети (127.0.0.1,10.*,172.16–31.*,192.168.*). Полеclientв/api/healthпоказывает, кем сервер тебя видит: если там адрес прокси, а не твой, все посетители делят один лимит, и прокси нужно настроить на передачуX-Forwarded-For.
GET /api/ready отвечает 200, когда каталог, таблица недель и индекс всех документов загружены, а до этого — 503 с кодом not_ready и списком того, что ещё грузится (waiting). На него смотрят проверка здоровья Docker и прокси. GET /api/health всегда отвечает 200, пока процесс жив: поле ready — то же, что /api/ready, плюс версии и учебный год. Возраст каталога, найденные файлы недель и звонков, семестры и предупреждения (папка нового года не найдена, сегодняшняя дата вне таблицы недель, источник не обновляется) — на дашборде /status.
npm run sync
Серверный кэш находится в .data/ (исключён из Git). Загрузки пользователей обрабатываются в памяти; временные файлы конвертации .doc удаляются. Данные из университетского каталога могут сохраняться в серверном кэше. Для офлайн-просмотра расписания хранятся в localStorage; очистка данных браузера удалит сохранённые документы. Аналитики и учётных записей нет.
Границы парсера
Основной парсер читает XML и сетку таблиц .docx, включая горизонтальные и вертикальные объединения ячеек. Поддерживаются проверенные дневные шаблоны ФЭФ, ФИТ, ФКНЭ, ИСФ и ГФ, заочные установочные сессии с отдельными колонками предмета, аудитории и преподавателя, а также заочные лабораторно-экзаменационные сессии.
Лабораторно-экзаменационные сессии заочников (проверено на зимней сессии 23-ИТзс 2024 года и летней 2026 года, test/fixtures/itzs-*.docx) устроены иначе:
- Группа. Указана только в заголовке («группа 23-ИТзс»).
- Недели. В таблице две–три недели стоят рядом блоками столбцов «Д.н. — Время — Дисциплина», у каждого дня своя дата. Сессия поэтому не зависит от таблицы недель и показывается по своим датам даже в прошлом учебном году. API отдаёт её границы в поле
semesterс названием «Лабораторно-экзаменационная сессия». - Подгруппы. Если под одним заголовком дисциплины столбец разделён, левая часть — подгруппа 1, правая — подгруппа 2, даже при неровном делении.
- Типы и предмет. Распознаются установочные лекции и занятия (
уст.л.,уст.пз.), экзамены, зачёты, консультации и защита курсовой работы. Предмет берётся из кавычек («…по учебной дисциплине «Базы данных»»). - Время. Если у экзамена указано только начало («8.30»), конец берётся по расписанию звонков.
Расписания зачётов (папка «Расписание зачетов», проверено на 23-, 24- и 26-АРХ ИСФ, test/fixtures/isf-credits-26-arh.docx) — таблица «Дата — Время — группа»:
- Дата и время. В ячейке дня дата стоит перед днём недели («27.10.2026 вторник»), пустая ячейка продолжает день выше. Указано одно время — начало, конец берётся по расписанию звонков. Такое одиночное время теперь принимается и в других таблицах: прежде в документах «Введение в высшее образование» и в установочных сессиях пропадали строки вроде «Торжественное собрание 10.30».
- Тип записи. Строка без пометки считается зачётом, «Консультация по учебной дисциплине…» — консультацией. Все они попадают в обратный отсчёт сессии.
Это не универсальный парсер любых документов Word: без заголовков групп, дней и времени импорт возвращает понятную ошибку.
Даты занятий рассчитываются по университетской таблице «Расписание белых и зеленых недель» из корня каталога: неделя 1 начинается 01.09.2026 (вторник), нечётные недели белые, чётные зелёные. Пометка в белой или зелёной строке относится только к неделям этого цвета: 1-13н в белой строке — недели 1, 3, …, 13, а в объединённой строке — каждая неделя. с 3н. означает «до конца таблицы», а части пометки через ; или , (1-4н.;6-12н., 2-5 н., 10-17 н.) складываются. Текстовые даты вроде «15 октября» и списки дат числами («10.09;15.10;19.11;17.12», не меньше двух дат, чтобы время пары не путалось с датой) привязываются к своему дню. Нераспознанные пометки (2,6-12-16н) не скрываются, а помечаются для сверки с Word; исходная пометка всегда видна. Разбор пометок общий для сервера и браузера (shared/weeks.js), поэтому старые сохранённые расписания тоже получают даты. Вне периода таблицы приложение показывает все строки Word. Номера подгрупп соответствуют столбцам внутри группы; именованных подгрупп в этих шаблонах нет.
Поля предмета, преподавателя и аудитории извлекаются по типовым обозначениям; сложные многострочные записи могут содержать несколько преподавателей/аудиторий. Поэтому полный текст всегда доступен в деталях. Если в ячейке явно указано особое время, например 16.50 для кураторского часа, 9.00, 18.35., интервал 11:10-11:30 или несколько времён (17.00 и 18.35, 18.35+20.10 — тогда это отдельные занятия), оно имеет приоритет; окончание не выдумывается. Дата в начале ячейки (13.10, 3.12, 24.11., 12.09.26г.) делает запись разовой. Запись вида 20.10 может быть и датой, и началом восьмой пары: если такая дата приходится на другой день недели, чем строка, или после неё идёт пометка недель, это время.
Аудитории и преподаватели по неделям и датам (server/rooms.js):
- Перенос на дату. «412н 26.10.2026‑333», «402н (13.10‑ауд.510н)», «14.10. ‑ауд. 333» — в этот день занятие идёт в другой аудитории, в остальные дни — в обычной. Год в дате необязателен. Расписание по датам отдаёт аудиторию дня с пометкой
roomMoved. - План по неделям. Строка под занятием, где есть только недели, аудитории или преподаватели, продолжает это занятие, а не становится новым. Например: «1‑9н. 502н / с 10н. – 359», «1‑13н‑332, 14‑17н.‑502н», «2‑9н.‑Клос О.Ю., 10‑14н.‑Конторов В.Э. 319Д». Аудитория в конце такой строки относится ко всем её частям.
- План по цвету недели. «бел.н.504н/зел.н.524н», «б.н.301Г/з.н.317Д» и с цветом после аудитории — «ауд.533н-б.н.,420н-з.н.» (25-ДО, 26-ДО, 23-ДО, АРХ, ПГС, ТТиИ и др., парсер 15). Номер аудитории перед пометкой цвета («32н-з.н.») не принимается за 32-ю неделю.
- Как отдаётся. План лежит у занятия в поле
plan. Расписание по датам и справочник «Кто где» берут аудиторию и преподавателя той недели; PWA — тоже (общийplanForизshared/lessons.js), а в режиме «Все строки Word» показывает аудитории обоих цветов: «533Н (бел. н.), 420Н (зел. н.)». Весь план виден в деталях занятия. В неделе, которой нет в плане, не показываются аудитории и преподаватели, указанные только для других недель.
Ещё форматы ячеек:
- Вид без предмета. Строка «7,9,11н. – (п.з.) Савицкая К.Д. … 8,12,14н. – (п.з.) …» — тот же предмет, что выше, в другие недели, с этим видом.
- Разный вид по неделям. «1‑11н. ‑ л., 12‑14 – п.з. Защита населения…» — лекция на одни недели, практика на другие.
- Дисциплины по выбору и специализации.
- Заголовок «Дисциплины по выбору:» или «Дисциплина специализации: 4‑14 н. …» — не занятие, и он не скрывает недели.
- Строка «Социология 1‑10 н. (л.) … 11‑16 н. (п.з.) …» с неделями после названия становится занятиями по видам.
- Строка с предметом и аудиторией над строками по видам — заголовок: её аудитория переходит к занятиям под ней.
- Совмещённый вид. «(л./п.з.)» и «(л. и п.з.)» — вид «Лекция и практика».
- Подгруппа в тексте. «12н.2п/гр. Экономика…» — занятие одной подгруппы.
- Разрядка (парсер 19): «Ф и з и ч е с к а я к у л ь т у р а», «З а н я т и я в П о л о ц к е» — буквы через пробел, слова через несколько. Склеивается до сжатия пробелов: «Физическая культура», «Занятия в Полоцке» (теперь это и заметка о городе). Инициалы и короткие слова не трогаются.
- «со 2 по 14 нед.» читается как «2-14 нед.»; пробелы внутри кавычек («« Майнор »») убираются.
- Вид занятия после преподавателя (парсер 18): «Русский язык как иностранный Козыренко А.Н.(п.з.) ауд.100» — название кончается там, где начинается преподаватель или вид, смотря что раньше. Раньше фамилия попадала в название (25‑ЭПи, 25‑ЭМ).
- Ячейка из одной точки — не занятие.
- Время в начале ячейки:
- интервал с дефисом «17:00‑17:20‑информационный час»;
- заголовок со временем «18.35+20.10 Новополоцк:» передаёт время занятиям под ним.
- Длинная запись под заголовком заглавными («ВВЕДЕНИЕ В ВЫСШЕЕ ОБРАЗОВАНИЕ / г. Полоцк, …») называется этим заголовком.
Группы с направлением в скобках — «24‑ИЯ (РА)и‑1» … «24‑ИЯ (РА)и‑6» — разные группы. Они пишутся, как у университета, с пробелом перед скобкой. До версии парсера 13 они сливались в одну «24‑ИЯ(РА)» со всеми занятиями трёх групп. Студентам с такой сохранённой группой нужно выбрать свою группу заново. На старое имя /api/timetable отвечает 404 group_not_found и в тексте ошибки называет новые группы. Имя без пробела («24‑ИЯ(РА)и‑1») по‑прежнему находит группу.
Занятия в другом городе (server/places.js, парсер 14). Большинство пар — в Новополоцке, но часть факультетов учится в Полоцке, и Word отмечает это по‑разному:
- «Занятия в Полоцке» над первой парой дня (24‑ИЯ (РА)и‑1…6, 25‑ИЯ (РА)и‑3, 24‑РГФи‑1, 22‑ЛОМК, 25‑ТДи) — не занятие, а заметка дня для столбца: в этот день все пары группы или подгруппы идут в том городе (и аудитории у них полоцкие — Г, Д, Е, Ж). Заметка лежит в разобранном документе в
places({day, date, groups, subgroups, place, text}), занятиям этого дня проставляетсяplace. Если в той же ячейке под заметкой есть пара, это обычное занятие без приставки в названии. Занятие всей группы получает город, когда заметка стоит во всех её подгруппах. - Город строкой в ячейке занятия — «Новополоцк», «г.Новополоцк» — или после аудитории — «246(Новополоцк)», «121(г.Новополоцк)» — город этого занятия; из названия и аудитории он убирается.
- Заметка посреди дня (25‑ИТ, пятница: «Занятия в Полоцке» в строке 13:40) действует с этой пары: в разобранном документе у неё
from, утренние пары города не получают./api/timetableотдаётfromвplaces, Android пишет «С 13:40 — занятия в Полоцке». - Пишется и «Занятия в г. Новополоцк», «Занятия проходят в …», «Занятия в Междуречье». Город приводится к именительному падежу («Полоцке» → «Полоцк»). Название предмета вроде «История Полоцка» городом не считается.
- В документах «Введение в высшее образование» строка «г. Полоцк, Дом офицеров» даёт занятию город, а место проведения остаётся в тексте.
- Какие заметки показать над днём группы, решает общий модуль
shared/places.js: им пользуются и/api/timetable, и PWA. Одинаковая заметка во всех подгруппах сводится к одной для всей группы, а заметка своей подгруппы показывается как заметка всего дня.
Занятия на выбор (парсер 16). У 23‑, 24‑ и 25‑РГФ‑1 одна ячейка на всю группу содержит две пары сразу: «Немецкий язык (п.з) – Найверт Ю.О. 208А, / Французский язык (п.з.) – Глобенко М.В. 336Е». Это не подгруппы (ячейка не делится пополам): часть группы учит немецкий, часть французский. Так же записаны «Белорусский язык» и «Стилистика русского языка» у 26‑РГФ и две дисциплины по выбору у 23‑ИТ.
- Ячейка, где каждая строка — целая пара (предмет, вид в скобках, преподаватель), делится на отдельные занятия со своими преподавателем и аудиторией (
parallelвserver/parser.js). Строка без предмета («321Д») остаётся при паре над ней. До версии 16 такая ячейка была одной парой «Немецкий язык» с двумя преподавателями и аудиторией французского. - Каждое такое занятие получает
option— свой предмет, а документ —choices({groups, options}). Тот же предмет в других ячейках этих групп («Немецкий язык» в четверг без французского) — тот же вариант и тоже получаетoption. /api/timetable(API 15) отдаёт у занятияoption, а у группы —choices:[["Немецкий язык", "Французский язык"]]. Клиент, который о них не знает, просто показывает обе пары.
Пары для иностранных студентов и для остальных (парсер 17). «Русский язык как иностранный» и пары с пометкой «(для иностранных студентов)», «(для иностр.студ.)» — только для иностранцев, пары «(кроме иностр.студ.)» — только для остальных. Занятие получает audience: "foreign" или "local".
- Пометка — не часть названия и аудитории: раньше «ауд.36н (для иностранных студентов)» давало аудиторию «36н(дляиностранныхстудентов)».
- В одной ячейке часто две пары сразу: иностранцы на РКИ, остальные на физкультуре или другом занятии. Такая ячейка делится на две, у каждой свой
audience:- «Ф и з и ч е с к а я к у л ь т у р а / Русский язык как иностранный (для иностранных студентов) …» (25‑ГЕО, 25‑КБ, 25‑ЭТ, 24‑СК, 24‑ЭБ);
- «Русский язык как иностранный (п.з.) Немирская В.А. 331Е ФИЗИЧЕСКАЯ КУЛЬТУРА» (25‑ и 26‑РГФ): раньше физкультура пропадала, а у РКИ не было аудитории;
- «3‑14н. Великая Отечественная война … 220Д / Русский язык как иностранный … 201Г»: раньше одна пара с аудиторией РКИ.
- «24‑КФ: Русский язык как иностранный …» в ячейке на 24‑КБ и 24‑КФ — занятие только 24‑КФ.
- Город или время строкой выше («17.00 Новополоцк») и преподаватель строкой ниже — не отдельная пара.
- Две пары, обе с пометкой («Теория языка (кроме иностр.студ.)» и «История языка (для иностр.студ.)» у 24‑РГФ‑1), — не выбор предмета, а пары для разных студентов.
- В группах иностранцев («25‑ДОи», «24‑ТНГи‑2») РКИ — пара для всех,
audienceу неё нет. /api/timetableотдаёт у занятияaudience, а у группы —audience: true, если в ней есть такие пары.
Для старых .doc на сервере нужен LibreOffice Writer (soffice в PATH или LIBREOFFICE_BIN). Конвертация может изменить сложное форматирование, результат нужно сверить. Реальные документы выбранного источника — .docx; поддержка .doc проверяется отдельно на образце, преобразованном LibreOffice.
PDF, Excel, изображения и сканы пока не импортируются. Они не выдаются за распознанное расписание.
Подозрительные ячейки. После разбора каждый документ каталога проверяется (server/diagnostics.js), и GET /api/diagnostics показывает ячейки, которые прочитаны неуверенно, с исходным текстом. Фильтры: ?group=23-АРХ, ?doc=<id>, ?code=weeks_partly. Их число и список с фильтрами — на дашборде /status. Коды:
weeks_partly— пометка недель прочитана не целиком (1-4н.;6-12н.);weeks_unreadable— пометка недель не прочитана (2,6-12-16н);weeks_like_date— «недели» похожи на дату с опечаткой (19,11, 03.12);weeks_out_of_range— номер недели больше, чем недель в семестре;date_in_subject,time_in_subject— дата или время в начале ячейки попали в название (24.11.,9.00,11:10-11:30);dates_unread— в ячейке есть даты, но ни одна не распознана;date_weekday— дата приходится на другой день недели, чем строка;date_out_of_year— дата вне учебного года;subject_missing— название предмета не выделено;time_invalid— время пары неправдоподобно;group_empty— у группы из заголовка нет ни одного занятия.
Такие ячейки — повод сверить документ с Word и, если это новый формат, научить парсер.
Проверки
npm test
npm run build
Тесты используют реальные Word-документы источника в test/fixtures/ с URL и метаданными рядом. Результат разбора каждого из них вместе с найденными диагностикой ячейками сравнивается построчно со снимком в test/snapshots/. Если разбор изменился намеренно, снимки обновляются командой UPDATE_SNAPSHOTS=1 npm test; разницу в test/snapshots/ стоит прочитать перед коммитом. HTTP-тесты (test/api.test.js) запускают настоящий сервер против локальной подмены Nextcloud университета, которая отдаёт тестовые документы как маленький каталог, с одним документом, отвечающим ошибкой, и одним слишком медленным. Проверяются готовность, форма ответа /api/timetable, 304 по ETag, коды ошибок (400, 404, 422, 429, 502, 504), диагностика, импорт с лимитом и переиспользование соединений. Для этого сервер читает переменные PSU_SOURCE_ORIGIN, PSU_DATA_DIR и PSU_SOURCE_TIMEOUT_MS; в работе их задавать не нужно. Также проверяются изоляция 23-БА/23-ЛГ, общие события, объединения ячеек, варианты недель, отдельное время кураторского часа, даты сессии и отклонение повреждённых файлов, а также таблица недель и звонков, разбор пометок с учётом цвета строки и согласие всех пометок 23-БА с таблицей.
Перед изменением парсера стоит снять снимок всего каталога и после правки сравнить с ним:
npm run snapshot -- fetch # скачать все документы каталога в .data/originals/
npm run snapshot -- save # разобрать их текущим парсером и сохранить эталон
npm run snapshot # после правки: что изменилось по каждому документу
Проверка выводит по каждому документу пропавшие и появившиеся занятия (с --all — все, иначе первые десять) и как изменилось число подозрительных ячеек по кодам. Если что-то изменилось, команда завершается с кодом 1. Документы и эталон лежат в .data/ и в git не попадают.
Docker
docker compose up -d --build
Приложение откроется на http://localhost:3001. Кэш каталога, таблицы недель и распознанных документов хранится в томе psu-data и переживает пересборку. Контейнер считается здоровым, когда сервер готов (/api/ready): с кэшем в томе — через несколько секунд после старта, при первом запуске — после того как скачаны и разобраны все документы каталога; на это даётся две минуты. Порт и адрес меняются переменными APP_PORT и APP_BIND.
Для установки PWA на телефон нужен HTTPS. Профиль https поднимает Caddy, который сам получает сертификат Let's Encrypt (домен должен указывать на сервер, порты 80 и 443 открыты):
DOMAIN=schedule.example.by docker compose --profile https up -d --build
Если HTTPS уже даёт ваш обратный прокси, профиль не нужен; приложение можно закрыть от внешней сети через APP_BIND=127.0.0.1. Контейнер включает LibreOffice для .doc. Файл docker-compose.yml проверен docker compose config; сборка образа в этой проверке не запускалась.
Android-приложение
В папке android/ — нативный клиент на Kotlin и Jetpack Compose с компонентами Material 3 Expressive (MaterialExpressiveTheme, ModalWideNavigationRail и WideNavigationRail, соединённые ToggleButton, LoadingIndicator). Базовая тема синяя (тональная схема M3); с «Цветами обоев» — динамическая, а цвета предметов и зелёной недели гармонизируются с ней. Разделы открываются из бокового меню: на телефоне оно выезжает, на планшете в портрете и на телефоне в альбомной ориентации — узкая боковая панель, на планшете в альбомной ориентации меню открыто постоянно. На широком экране разделы делятся на две панели, как в School Planner: «Обзор» — карточки и недельная сетка, «Планы» и «Календарь» — список и выбранный день, «Поиск» — результаты и день преподавателя или аудитории; «Предметы» — сеткой карточек, «Расписание» открывается неделей; карточки лежат на подложке с оттенком, системные панели следуют теме приложения.
Складные телефоны. Приложение следит за сгибом экрана через Jetpack WindowManager (FoldingFeature, ui/components/Fold.kt).
- Фолды (Galaxy Z Fold, Pixel Fold) в разложенном виде — окно через сгиб с короткой стороной от 600dp — получают планшетный макет:
- «Обзор», «Планы», «Календарь» и «Поиск» — две панели, как две страницы, по обе стороны вертикального сгиба, и в плоском, и в полураскрытом виде;
- «Расписание» открывается неделей на карточке;
- меню остаётся узкой полосой, а раскрытое ложится поверх экрана с затемнением и не сжимает левую панель.
- Флипы (Galaxy Z Flip, Razr) — окно размером с телефон — сохраняют телефонный макет.
- «Ноутбук» — телефон приоткрыт на столе, сгиб горизонтальный. «Расписание» по дням становится настольной подставкой:
- над сгибом — день и одна пара крупно: время, обратный отсчёт или полоса прогресса идущей пары, аудитория и преподаватель; на широком экране рядом — «Дальше»;
- под сгибом — панель, лента дней и карточки пар дня: касание показывает пару наверху, повторное — открывает подробности;
- неделя показывает сетку над сгибом.
Проверено на эмуляторе Pixel 10 Pro Fold (OPENED, HALF_OPENED в портрете и в альбомной ориентации) и на AVD «6.7in Foldable» (флип: телефонный макет). Универсальный AVD флипа сообщает приложению сгиб всегда в состоянии FLAT, поэтому «ноутбук» на флипе проверен только на фолде.
- Запуск. Заставка (
core-splashscreen, одинаково на Android 8–16): на фоне приложения рисуется иконка — календарь вырастает, кольца опускаются, зелёная галочка дорисовывается (около 750 мс, на Android 12+ анимированно). Заставка держится, пока не прочитаны сохранённые настройки, поэтому пустого кадра нет. Потом иконка раздувается и растворяется, а карточки «Обзора» поднимаются одна за другой (один раз за запуск). - Отмена вместо подтверждений. Удалённое задание или заметка и скрытая пара сразу пропадают, а снизу 10 секунд висит «Задание удалено · Вернуть» или «Скрыто: «…» · Вернуть». Фото удалённого задания стираются, только когда вернуть уже нельзя. В подробностях пары и в листе предмета snackbar показывается прямо в них.
- Сегодня — на текущей паре. Страница сегодняшнего дня открывается на идущей паре (или следующей), а не на первой утренней.
- Виброотклик: тик при выборе дня в ленте и при листании дней пальцем, при выборе в кнопках-переключателях. Подтверждение при выполнении задания и отметке посещения, щелчок у переключателей настроек.
- Скелетоны вместо спиннера: пока грузится первое расписание группы, «Обзор», «Расписание» и «Предметы» показывают серые заготовки в форме своих карточек с мягким бликом. Так же выглядят занятия преподавателя или аудитории в поиске и каталог групп. Для TalkBack это одно «Загрузка». При системной настройке «Убрать анимацию» блик, появление карточек и прочие украшения отключаются.
- Карточка вырастает в подробности (container transform). Пара открывается из своей карточки: из «Обзора», расписания по дням, недельной сетки, календаря «Планов» и подставки «ноутбука». Рамка плавно растягивается до полного экрана (на планшете — до большой панели по центру), а содержимое едет вместе с ней, как в M3 (container transform из Material Components): и карточка, и страница подробностей масштабируются по ширине рамки от её левого верхнего угла, так что заголовок и сведения о паре выходят из самой карточки. Страница проявляется поверх карточки за первую четверть пути, углы выпрямляются за три четверти; при закрытии она гаснет поздно, между 60 и 90 % пути, и рамка с карточкой внутри сжимается на место. «Назад», предиктивный жест и касание за панелью сворачивают её обратно в карточку. Так же «+» в «Планах» и календаре раскрывается в лист нового задания; лист можно стянуть вниз за ручку, и он тоже сжимается обратно в «+». Контейнер рисует только содержимое источника (иконку «+», текст карточки), а заливку, углы и тень — свои: у «+» тень плавно уходит при росте и возвращается к его уровню при посадке, так что ни второй формы внутри, ни скачка тени в конце нет.
- Живая текущая пара. Вокруг идущей пары по часовой стрелке от левого верхнего угла рисуется рамка: какая часть пары прошла. Она подрастает плавно, без скачков. Цифры обратного отсчёта («ещё 44 мин», «Через 15 мин») прокручиваются, как на табло. Линия «сейчас» в недельной сетке ползёт вниз вместе со временем.
- Лента дней. Выбранный день — один круг под всей лентой: при выборе другого дня он перетекает туда на пружинах, передний край спешит, задний догоняет, и по дороге круг вытягивается в каплю. В «Расписании» точка в круге дня значит «есть пары». Когда пролистываешь на другую неделю, её точки появляются по очереди, с понедельника.
- Долгое нажатие на пару (в расписании, недельной сетке и «Обзоре») открывает лист действий: «Подробнее» (страница вырастает из той же карточки), «Задание или заметка», «Был / Пропустил», когда пара уже началась, «Поделиться» (предмет, вид, день, время, аудитория, преподаватель) и «Скрыть такие занятия» с «Вернуть». Для TalkBack — действие «Действия» у карточки.
- Между парами в расписании дня — строка, когда есть что сказать: «Окно 1 ч 35 мин» (от 45 минут, то есть пропущена пара), «Другой корпус: 211А → 254В · перерыв 30 мин» (корпус — буквы после номера аудитории), «Переезд в Полоцк» по городу пары; переезд с перерывом меньше часа выделен красным. Обычные перерывы 10–30 минут и дистанционные пары строки не дают.
- Избранное и недавние в поиске. Звёздочка у преподавателя или аудитории добавляет их в «Избранное»; до ввода запроса поиск показывает «Избранное» и «Недавние» (последние восемь открытых). Касание открывает сегодняшний день, и у идущей пары — «Идёт · ещё 40 мин» с рамкой прогресса, у ближайшей — «Через 15 мин»: где преподаватель сейчас, видно сразу.
- Свайпы по заданиям (как в Buckwheat). Вправо — «Выполнено», у выполненного — «Не выполнено». Влево — «Перенести»: задание переезжает к следующей паре того же предмета, общий план — на завтра; внизу «Перенесено на пн, 26 октября, 15:20 · Вернуть». Под строкой открывается прямоугольная полоса с иконкой действия; за порогом цвет действия заливает её кругом от иконки, иконка подпрыгивает, телефон щёлкает. Отпущенная строка всегда пружинит на место. Пока студент ни разу не свайпнул задание, одна строка на экране сама покачивается в стороны и показывает действия под собой; касание её останавливает, первый свайп убирает подсказку насовсем. На «Доске» свайпов нет: там жест листает дни. Для TalkBack те же действия — в меню действий строки.
- Смена темы. «Светлая», «Тёмная» и «Цвета обоев» в настройках меняют тему кругом, который расходится от нажатой кнопки: снимок старого экрана лежит поверх приложения, и в нём растёт дыра с новой темой (
ui/theme/ThemeReveal.kt). Значки строки состояния рисует система, они меняются сразу. - Выполненное задание. При отметке текст зачёркивается слева направо, строка за строкой, и задание чуть задерживается на месте, прежде чем уйти к выполненным. Когда закрыто последнее открытое, у «Всё сделано» выскакивает галочка.
- Обновление жестом. Потянуть вниз в «Обзоре» и в расписании: экспрессивный индикатор M3 с меняющейся формой и щелчок отклика, когда тянуть уже достаточно.
- Свежие изменения на виду. Если университет перезалил документ и пары изменились или добавились, их карточки в расписании дважды вспыхивают цветом (один раз, при первом показе).
- Первый запуск — короткий онбординг: приветствие с тремя возможностями приложения, затем шаги с волнистым индикатором прогресса: группа, подгруппа (только если группа делится на подгруппы) и напоминания. Разрешение на уведомления спрашивается на шаге напоминаний, его можно отложить; «Назад» (с предиктивным жестом) возвращает на предыдущий шаг. После онбординга открывается «Обзор» выбранной группы.
- Обзор: отчёт за неделю (сколько пар прошло), следующие занятия сегодня с обратным отсчётом и ближайший учебный день.
- Расписание группы по дням с листанием или недельной сеткой по часам; неделя и её цвет, номера пар, текущая пара, детали занятия с исходной записью Word. Если семестр не найден в таблице недель — строки Word по дням недели.
- Занятия на выбор (немецкий или французский у РГФ). После выбора группы (и подгруппы) приложение спрашивает «Что вы изучаете?», в онбординге это отдельный шаг. Пары другого варианта скрываются из расписания, «Обзора», «Планов», виджетов, напоминаний и уведомлений. Изменить выбор можно в «Группах», в карточке «Сейчас открыта». Пока вариант не выбран, видны обе пары с меткой «на выбор».
- Иностранные студенты. В группах, где есть пары только для иностранцев («Русский язык как иностранный») или только для остальных, приложение спрашивает «Вы иностранный студент?» (и в онбординге). Ответ «Да» скрывает пары «кроме иностранных студентов», «Нет» — РКИ и другие пары для иностранцев. Изменить — в «Группах», строка «Иностранный студент». На карточке пары — метка «для иностранных студентов» или «кроме иностранных студентов».
- Предметы: пары по дням текущей недели; у каждого предмета — сокращение как в недельной сетке, следующее занятие с аудиторией, преподаватели и сколько пар на этой неделе. У каждого предмета свой постоянный цвет во всех разделах.
- Планы: задания и заметки по дням — к занятию или общие, без привязки к паре, на выбранный день, просроченные сверху, «+» прикрепляет задание к ближайшему занятию. Три макета, как в School Planner: «Повестка дня» (все дни с липкими заголовками и полосой недели, которая следует за списком), «Список» (только дни с делами) и «Доска» (колонки дней). В настройках вида — пары дня и выполненные задания.
- Календарь: месяц с отметками дней с парами и с планами, зелёные недели подсвечены; под ним лист с планами и парами выбранного дня. Лист можно поднять — месяц сворачивается до выбранной недели.
- Обратный отсчёт до сессии в «Обзоре»: дни до ближайшего экзамена или зачёта из
/api/exams(консультации — только в списке), по нажатию — вся сессия. Пока университет не опубликовал сессию, отсчёт идёт до конца семестра. Экзамены кэшируются на устройстве. - Занятия в другом городе. Если в Word над днём написано «Занятия в Полоцке», над парами дня появляется карточка «Занятия в Полоцке · пометка в Word на этот день» — на странице дня, в «Календаре», в «Обзоре» (сегодня и ближайший день) и на подставке «ноутбука», а в недельной сетке под датой — метка «Полоцк». Если заметка относится к одной подгруппе, а выбраны все, в карточке указана подгруппа. В карточках пар город стоит рядом с аудиторией («249Ж · Полоцк»), в подробностях — строка «Город»; так же — в виджетах, плитке и напоминаниях. Раньше такая заметка показывалась отдельной парой «Занятия в Полоцке».
- Группа, которой больше нет. Если сервер отвечает
group_not_foundилиdocument_not_found— группу разделили («24‑ИЯ(РА)» стала «24‑ИЯ (РА)и‑1»…), или документ ушёл из каталога, — плашка с текстом сервера предлагает «Выбрать группу». Сохранённая копия остаётся на экране, а устаревшая группа убирается из недавних. - Изменения в расписании. Как в PWA: когда университет перезаливает документ группы, приложение сравнивает новое расписание с копией на устройстве. В «Обзоре» появляется карточка «Расписание обновлено · N изменений» с кнопкой «Показать», а в списке видно, что изменилось, что добавлено и что удалено, например «Аудитория: 374 → 375». Карточка показывается до просмотра или закрытия, последний список остаётся в настройках («Последние изменения»). Сравнение срабатывает и при фоновом обновлении (
SyncWorker, раз в 6 часов). Если изменения нашлись в фоне, приходит уведомление «Расписание 23-АРХ изменилось» с первыми изменениями, например «Проектирование общественных зданий: аудитория 374 → 375». Уведомление локальное, сервер push для него не нужен. Его можно выключить в настройках («Изменения в расписании», по умолчанию включено), а на Android 13+ нужно разрешение на уведомления. Считается только новая версия того же файла: смена версии парсера или переход на другой документ изменениями не считаются. Сравниваются строки Word по дням недели и разовые занятия со своей датой, а не каждая рассчитанная дата. - Задания и заметки к паре: из карточки занятия — к этой паре или к следующей паре того же предмета. Задания отмечаются выполненными, просроченные подсвечиваются; список — в «Обзоре», по предмету — в «Предметах», точка на блоке в недельной сетке. Хранятся только на устройстве (
notes.json) и привязаны к группе. - Фото к заданиям: в редакторе задания или заметки — «Снять» (приложение камеры) и «Галерея» (системный выбор фото, без доступа к памяти), до 6 фото. Фото уменьшается до 2048 px по длинной стороне, поворачивается по EXIF и хранится в
files/photos/рядом с заметками. Удаление фото, задания или отмена правки убирает файлы, а при запуске удаляются фото, на которые не ссылается ни одна заметка. В списках у задания миниатюра (с «+N»), на карточке пары — значок фото. Просмотр — на весь экран, с листанием, масштабом щипком и двойным касанием. Он открывается по M3 как container transform для «героического» момента: фото вырастает из миниатюры (в строке задания или в редакторе) из квадратной обрезки в целый снимок, углы выпрямляются, вокруг поднимается чёрный фон, а при закрытии (крестик, «Назад», предиктивный жест) фото сжимается обратно в свою миниатюру; у фото без своей миниатюры — в ту, из которой открыли, и на последнем отрезке перетекает в её снимок. Миниатюра запоминает место на экране, поэтому переход работает и из диалога редактора. Задание может быть и одним фото, без текста. - Посещаемость: «был» или «пропустил» в карточке занятия и одним касанием в «Обзоре» для прошедших сегодня пар; статистика по каждому предмету в «Предметах». Хранится на устройстве (
attendance.json). - Скрытие занятий: «Скрыть такие занятия» в карточке занятия убирает предмет с этим типом занятия из расписания, обзора, виджетов и напоминаний; вернуть можно в настройках.
- Второй виджет «Следующая пара» (4×1) и плитка в шторке быстрых настроек с текущей или следующей парой и аудиторией.
- Виджет «Неделя» (4×2 и больше): номер и цвет недели, по плитке на день (Пн–Сб, воскресенье — только если в нём есть пары) с числом пар. Сегодня выделено. Значок города — день в другом городе, значок стрелок — перенос аудитории, внизу строка вроде «Полоцк: вт, пт · перенос: пн, чт». Если растянуть виджет в высоту, в плитках появляются сокращения пар, как в недельной сетке. В воскресенье виджет показывает следующую неделю. Нажатие на день открывает этот день, на заголовок — неделю.
- Настройки отображения: цвета предметов, преподаватели, аудитории, номера пар, метки занятий и пометки из Word можно скрыть; полные сведения остаются в карточке занятия.
- Группы (кнопка группы в верхней панели «Обзора»; если у группы есть подгруппы, после выбора приложение предложит свою): поиск по номеру во всём каталоге, недавние, каталог по факультету и курсу, выбор подгруппы.
- Поиск по университету: занятия преподавателя или аудитории на выбранный день.
- Виджет «Сегодня» (Glance): текущая и следующая пара, обновляется каждые 15 минут без сети.
- Напоминания перед парой за 5–30 минут — локальные будильники из сохранённого расписания, без push-сервера. С разрешением «Будильники и напоминания» приходят точно в срок, без него — в окне ±5 минут.
- Уведомления о дне (настройки → «Уведомления о дне», тоже локальные, из сохранённого расписания):
- «Занятия в другом городе» — вечером накануне такого дня (18:00–21:00, по умолчанию 20:00) приходит «Завтра занятия в Полоцке» с первой парой, аудиторией и числом пар. Напоминание о первой паре этого дня, если напоминания включены, приходит раньше обычного на 0–60 минут (по умолчанию 30).
- «Переносы аудиторий» — утром в день переноса (6:30–8:00, по умолчанию 7:00, но не позже чем за час до пары) приходит «Сегодня перенос аудитории: 15:20 Возрастная физиология… — ауд. 100, обычно 402Н».
- Оба включены по умолчанию. Нажатие открывает этот день в «Расписании».
- Текущая пара в шторке (выключена по умолчанию): пока идёт пара, в шторке висит уведомление — предмет, «до 11:30 · ауд. 205Г · Полоцк», следующая пара и прогресс по половинам пары из расписания звонков. На Android 16 это Live Update: в строке состояния плашка с обратным отсчётом до конца пары, на экране блокировки — само уведомление. Оно появляется по будильнику в начале пары, обновляется раз в несколько минут и само исчезает в конце.
- Ярлыки на иконке (долгое нажатие): «Пары на сегодня», «Неделя», «Найти аудиторию», «Новое задание» (открывает «Планы» с выбором пары).
- Офлайн: последнее расписание хранится на устройстве; каждые 6 часов WorkManager обновляет его, виджет и напоминания.
Экзамены, зачёты, консультации и защиты курсовых попадают в «Расписание», «Календарь» и «Планы», даже если университет публикует их отдельным документом («Расписание зачетов»): /api/timetable (API 6) добавляет к дням семестра события сессии группы из всех документов каталога и продлевает период до последнего из них (не дальше 60 дней после семестра).
- Как выделены. Янтарный цвет (красный читался бы как ошибка), значок вида события и рамка вокруг карточки.
- В «Планах» они видны всегда, даже с выключенными «Парами дня».
- В календаре день с экзаменом или зачётом отмечен янтарным квадратом, а точка планов стала фиолетовой.
Даты считает сервер: GET /api/timetable?doc=<id>&group=<группа>&from=ГГГГ-ММ-ДД&days=N (до 62 дней) или с &range=semester — весь текущий семестр от первого до последнего дня; приложение хранит его целиком и работает с ним без сети. Ответ содержит дни с неделями, номерами пар и пометкой uncertain для нераспознанных пометок, плюс строки Word (weekly) и подгруппы. С API 14 у занятия есть place — город, если документ его называет, а у дня с парами — places: заметки «Занятия в Полоцке» ({place, text, subgroup}, subgroup: null — для всей группы; одинаковая заметка во всех подгруппах сводится к одной). Поэтому правила белых и зелёных недель в Kotlin не дублируются.
Сборка (JDK 17, Android SDK 37):
cd android
./gradlew assembleDebug
Отладочная сборка ходит на http://10.0.2.2:3010/ — это сервер из docker compose на компьютере, видимый из эмулятора (APP_PORT=3010). Другой адрес: -PPSU_API_URL=…. Релизная сборка принимает только HTTPS-адрес. Идентификатор приложения ritm.schedule — рабочий, перед публикацией его стоит заменить своим.
iOS-приложение
В папке ios/ — нативный клиент на SwiftUI для iOS 26 и новее с Liquid Glass. Функции те же, что у Android-приложения, данные — из того же API. Проект ios/PSUSchedule.xcodeproj открывается в Xcode 27; папки подключены синхронизированными группами, поэтому новые файлы попадают в сборку сами.
- Онбординг: приветствие (три стеклянные «капли» сливаются в одну), затем группа, подгруппа (только если группа делится на подгруппы) и напоминания. Разрешение на уведомления спрашивается на последнем шаге.
- Стеклянная панель вкладок: три раздела — «Обзор», «Расписание», «Планы» — и поиск отдельной круглой кнопкой справа (роль
.search). Нажатие превращает кнопку в поле ввода над клавиатурой, как в приложениях Apple (tabViewSearchActivation(.searchTabSelection)). «Календарь» открывается кнопкой в «Планах», «Предметы» — карточкой в «Обзоре». На iPad панель превращается в боковое меню. Группа и настройки открываются из панели «Обзора»; тема меняется сразу во всех окнах, включая открытые настройки. - Занятия в другом городе: как в Android и PWA. Над парами дня, в «Календаре» и в «Обзоре» (сегодня и ближайший день) стоит карточка «Занятия в Полоцке», в недельной сетке под датой — метка «Полоцк». В карточках пар город стоит рядом с аудиторией, в подробностях — строка «Город»; так же — в виджетах и напоминаниях.
- Обзор: неделя (сколько пар осталось, чип на каждый день), обратный отсчёт до сессии или конца семестра, следующие занятия с отсчётом «Через 20 мин», посещаемость одним касанием, задания и ближайший учебный день. На iPad рядом показывается недельная сетка.
- Изменения в расписании: как в Android и PWA — карточка «Расписание обновлено · N изменений» в «Обзоре» после перезаливки документа, по «Показать» — лист с изменёнными, добавленными и удалёнными занятиями; последний список — в настройках. Сравнение срабатывает и при фоновом обновлении
BGAppRefreshTask: тогда приходит локальное уведомление (настройка «Изменения в расписании»). Когда запускать фоновое обновление, решает iOS, поэтому уведомление может прийти через несколько часов, а если приложение смахнули из переключателя — только после следующего открытия. - Расписание: дни листаются в сторону под плавающей стеклянной лентой недели. На зелёных неделях стекло окрашено зелёным. Есть недельная сетка по часам и строки Word, если семестр не найден. В подробностях пары: время по звонкам, посещаемость, задания, исходная запись Word и скрытие таких занятий.
- Планы: три макета — повестка дня с лентой недели, список и доска. Можно показывать пары дня и выполненные задания. Кнопка «+» — стеклянная.
- Календарь: месяц с отметками пар и планов, зелёные недели подсвечены; ниже — план выбранного дня.
- Предметы: нагрузка недели на диаграмме Swift Charts; у каждого предмета — следующее занятие, прогресс недели, посещаемость и задания.
- Поиск «Кто где»: занятия преподавателя или аудитории на день по всем документам каталога.
- Виджеты WidgetKit: «Сегодня» (малый, средний, большой) и «Следующая пара» (малый и на экране блокировки). Таймлайн обновляется в начале и конце каждой пары; нажатие открывает день в приложении (
psuschedule://day/ГГГГ-ММ-ДД). - Офлайн: расписание, экзамены, задания, посещаемость и настройки хранятся в общей папке App Group
group.org.xtimms.psu.schedule, её читают и виджеты.- Напоминания — локальные уведомления на неделю вперёд (до 48).
- Раз в несколько часов
BGAppRefreshTaskобновляет расписание. - Ответы сервера кэшируются
URLCache: неизменённый семестр приходит пустым304.
Адрес сервера задаётся ключом PSUAPIURL в ios/Config/App-Info.plist (по умолчанию https://psu.rumblur.by/). В Debug-сборке его можно заменить переменной окружения PSU_API_URL, например для локального сервера на этом Mac:
SIMCTL_CHILD_PSU_API_URL=http://localhost:3010/ xcrun simctl launch booted org.xtimms.psu.schedule
Для установки на устройство в Xcode нужно выбрать команду разработчика и зарегистрировать App Group.
Структура
src/App.tsx— интерфейс, каталог и импорт с предпросмотром.src/storage.ts— сохранённые расписания, предпочтения и календарь недель.src/calendar.ts,src/icons.tsx— подписи дат и пар, иконки Material Symbols.shared/weeks.js— разбор пометок недель и расчёт дат, общий для сервера и браузера.shared/lessons.js— устойчивый ключ занятия, сравнение версий расписания, фильтр подгруппы и скрытых занятий.shared/updates.js— правила проверки обновлений сохранённого расписания.server/directory.js— индекс всех документов: преподаватели и аудитории из текста ячеек, занятость на дату, свободные аудитории, экзамены группы (/api/directory/search,/api/directory/day,/api/directory/free,/api/exams).server/schedules.js— разбор документа каталога с дисковым кэшем.src/Search.tsx— экран «Поиск».server/source.js— DAV-каталог, фиксированный источник и кэш; таблица недель и звонков из корня каталога (/api/calendar, кэш 6 часов).server/calendar.js— разбор таблицы недель и расписания звонков.server/parser.js— таблицы.docx, условия и преобразование.doc.server/index.js— API и выдача собранной PWA.server/limits.js— ограничения импорта: число одновременных разборов и запросов с одного адреса.server/errors.js— коды ошибок API и их HTTP-статусы.android/app/src/main/kotlin/org/xtimms/psu/schedule/— приложение Android:data/— API, модели, настройки, задания и посещаемость;notify/,widget/,work/,tile/— уведомления, виджеты, фоновая синхронизация, плитка;ui/—MainViewModelи меню;ui/components/— общие элементы (заголовки, лента дней, скелетоны, отмена, свайпы, сгиб экрана),ui/motion/— переходы и container transform,ui/theme/— тема и её смена;ui/lesson/— карточка и страница пары, слоты дня, цвета предметов, отметки сессии, города, посещаемость;ui/notes/— задания, заметки и фото;- экраны:
ui/home/(обзор, изменения, сессия),ui/schedule/(дни, неделя, подставка),ui/plans/(планы, календарь),ui/subjects/,ui/groups/,ui/search/,ui/settings/,ui/onboarding/.
ios/Shared/— модели, даты в часовом поясе Минска, расчёт пар и общее хранилище; общие для приложения и виджетов.ios/PSUSchedule/— приложение iOS:AppModel.swift(состояние и загрузка),API.swift,Reminders.swift, экраны вViews/.ios/Widgets/— виджеты WidgetKit.scripts/serviceworker.js— генерация полного офлайн-кэша при сборке.
Это независимый клиент; университет остаётся источником расписания. Шрифт Roboto Flex поставляется локально под SIL Open Font License (public/fonts/OFL-RobotoFlex.txt).
Тема выбирается в меню: «Авто» (как в системе), «Светлая» или «Тёмная». Выбор хранится в браузере и применяется до первой отрисовки страницы, вместе с цветом строки состояния. QR-коды строятся в браузере библиотекой qrcode-generator (MIT). Иконки — Material Symbols Rounded (@material-symbols/svg-400, Apache 2.0), встроены в сборку и работают без сети. Интерфейс использует веб-адаптацию Material 3 Expressive: токены цвета и типографики, тональные поверхности, выразительные формы, навигационную панель и rail. Главный экран содержит расписание; импорт, установка и сведения о Word доступны в меню.