feat: read "Занятия в Полоцке" as a day's town, show it in all clients

- Parser 14 (server/places.js): "Занятия в Полоцке/Новополоцке/г. Новополоцк" over a day's first row is no longer a lesson but a note of the day for its column (`places`); lessons of that day get `place`. A lesson below the note in the same cell keeps its own subject.
- A town on its own line ("Новополоцк", "г.Новополоцк") or after a room ("246(Новополоцк)") becomes the lesson's `place` and leaves the subject and room. "История Полоцка" is not a town.
- API 14: `place` on lessons and `places` on days of /api/timetable, `place` in the directory. The day notes are shared by the server and the PWA (shared/places.js); the same note in every subgroup reads as the whole group's.
- Android, PWA and iOS: a "Занятия в Полоцке" card above the day's lessons (day pages, calendar, overview, week view, desk stand), "Полоцк" under the date in the week grid, "249Ж · Полоцк" on lesson cards, a "Город" row in details, the town in widgets, tile, reminders and change lists.
- Snapshot lines show the town and the day notes; tests for notes, towns in cells and the API.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-21 01:26:54 +03:00
parent 3d08d0cb70
commit 10c85e3bb9
50 changed files with 808 additions and 80 deletions

View File

@@ -12,6 +12,12 @@
"runtimeExecutable": "env", "runtimeExecutable": "env",
"runtimeArgs": ["PORT=3002", "node", "--watch", "server/index.js"], "runtimeArgs": ["PORT=3002", "node", "--watch", "server/index.js"],
"port": 3002 "port": 3002
},
{
"name": "vite-docker",
"runtimeExecutable": "env",
"runtimeArgs": ["API_PORT=3010", "npx", "vite", "--host", "127.0.0.1", "--port", "5174"],
"port": 5174
} }
] ]
} }

View File

@@ -29,6 +29,7 @@ Vite работает на http://127.0.0.1:5173 и проксирует `/api`
- Отдельные карточки для альтернативных занятий и условий по неделям. Сохраняются исходный текст, цвет ячейки, подгруппы, даты и ограничения. - Отдельные карточки для альтернативных занятий и условий по неделям. Сохраняются исходный текст, цвет ячейки, подгруппы, даты и ограничения.
- Учебные недели по таблице университета: переключатель недель с датами, показ только занятий выбранной недели, статус занятия («ещё 4 занятия · до 27 нояб.», «начнётся 20 нояб.») и сетка недель в деталях. Режим «Все строки Word» показывает документ как есть. - Учебные недели по таблице университета: переключатель недель с датами, показ только занятий выбранной недели, статус занятия («ещё 4 занятия · до 27 нояб.», «начнётся 20 нояб.») и сетка недель в деталях. Режим «Все строки Word» показывает документ как есть.
- Номера пар и текущая пара («Идёт · ещё 25 мин») по расписанию звонков. - Номера пар и текущая пара («Идёт · ещё 25 мин») по расписанию звонков.
- **Занятия в другом городе.** Если в Word над днём написано «Занятия в Полоцке», над парами этого дня — в виде «День» и «Неделя» — стоит карточка «Занятия в Полоцке · пометка в Word на этот день» (с подгруппой, если заметка только её). В карточке пары город стоит рядом с аудиторией («Ауд. 249Ж · Полоцк»), в деталях — строка «Город». Сохранённые раньше расписания перечитываются по версии парсера и получают города сами.
- «Что изменилось»: когда университет перепубликует документ, приложение сравнивает новую версию с сохранённой и показывает для вашей группы изменённые поля (аудитория, время, преподаватель, недели), добавленные и удалённые занятия. Последний список изменений открывается из меню. - «Что изменилось»: когда университет перепубликует документ, приложение сравнивает новую версию с сохранённой и показывает для вашей группы изменённые поля (аудитория, время, преподаватель, недели), добавленные и удалённые занятия. Последний список изменений открывается из меню.
- Моя подгруппа и скрытые занятия: в «Группах» можно выбрать подгруппу — занятия другой подгруппы пропадут; любое занятие можно скрыть из его карточки и вернуть кнопкой «Скрыто · показать». Настройки хранятся только в этом браузере. - Моя подгруппа и скрытые занятия: в «Группах» можно выбрать подгруппу — занятия другой подгруппы пропадут; любое занятие можно скрыть из его карточки и вернуть кнопкой «Скрыто · показать». Настройки хранятся только в этом браузере.
- **Поиск по университету** (вкладка «Поиск»): преподаватель или аудитория — занятия на выбранный день по всем расписаниям каталога; свободные аудитории на дату и пару. Сервер разбирает все документы каталога в фоне (первый раз около 20–30 секунд, дальше из кэша) и считает занятость с учётом недель, цвета строк, семестра и разовых переносов аудиторий вида «432 (07.10-ауд.272)». Дистанционные занятия («200 (ДО)») аудиторию не занимают. Каждый ответ сообщает, сколько документов учтено; «свободна» означает «свободна по расписаниям из Word», мероприятия и закрытые аудитории расписание не знает. Работает только онлайн. - **Поиск по университету** (вкладка «Поиск»): преподаватель или аудитория — занятия на выбранный день по всем расписаниям каталога; свободные аудитории на дату и пару. Сервер разбирает все документы каталога в фоне (первый раз около 20–30 секунд, дальше из кэша) и считает занятость с учётом недель, цвета строк, семестра и разовых переносов аудиторий вида «432 (07.10-ауд.272)». Дистанционные занятия («200 (ДО)») аудиторию не занимают. Каждый ответ сообщает, сколько документов учтено; «свободна» означает «свободна по расписаниям из Word», мероприятия и закрытые аудитории расписание не знает. Работает только онлайн.
@@ -163,6 +164,13 @@ npm run sync
**Группы с направлением в скобках** — «24‑ИЯ (РА)и‑1» … «24‑ИЯ (РА)и‑6» — разные группы. Они пишутся, как у университета, с пробелом перед скобкой. До версии парсера 13 они сливались в одну «24‑ИЯ(РА)» со всеми занятиями трёх групп. Студентам с такой сохранённой группой нужно выбрать свою группу заново. На старое имя `/api/timetable` отвечает `404 group_not_found` и в тексте ошибки называет новые группы. Имя без пробела («24‑ИЯ(РА)и‑1») по‑прежнему находит группу. **Группы с направлением в скобках** — «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(г.Новополоцк)» — город этого занятия; из названия и аудитории он убирается.
- Пишется и «Занятия в г. Новополоцк», «Занятия проходят в …», «Занятия в Междуречье». Город приводится к именительному падежу («Полоцке» → «Полоцк»). Название предмета вроде «История Полоцка» городом не считается.
- В документах «Введение в высшее образование» строка «г. Полоцк, Дом офицеров» даёт занятию город, а место проведения остаётся в тексте.
- Какие заметки показать над днём группы, решает общий модуль `shared/places.js`: им пользуются и `/api/timetable`, и PWA. Одинаковая заметка во всех подгруппах сводится к одной для всей группы, а заметка своей подгруппы показывается как заметка всего дня.
Для старых `.doc` на сервере нужен LibreOffice Writer (`soffice` в PATH или `LIBREOFFICE_BIN`). Конвертация может изменить сложное форматирование, результат нужно сверить. Реальные документы выбранного источника — `.docx`; поддержка `.doc` проверяется отдельно на образце, преобразованном LibreOffice. Для старых `.doc` на сервере нужен LibreOffice Writer (`soffice` в PATH или `LIBREOFFICE_BIN`). Конвертация может изменить сложное форматирование, результат нужно сверить. Реальные документы выбранного источника — `.docx`; поддержка `.doc` проверяется отдельно на образце, преобразованном LibreOffice.
PDF, Excel, изображения и сканы пока не импортируются. Они не выдаются за распознанное расписание. PDF, Excel, изображения и сканы пока не импортируются. Они не выдаются за распознанное расписание.
@@ -241,6 +249,7 @@ DOMAIN=schedule.example.by docker compose --profile https up -d --build
- Планы: задания и заметки по дням — к занятию или общие, без привязки к паре, на выбранный день, просроченные сверху, «+» прикрепляет задание к ближайшему занятию. Три макета, как в School Planner: «Повестка дня» (все дни с липкими заголовками и полосой недели, которая следует за списком), «Список» (только дни с делами) и «Доска» (колонки дней). В настройках вида — пары дня и выполненные задания. - Планы: задания и заметки по дням — к занятию или общие, без привязки к паре, на выбранный день, просроченные сверху, «+» прикрепляет задание к ближайшему занятию. Три макета, как в School Planner: «Повестка дня» (все дни с липкими заголовками и полосой недели, которая следует за списком), «Список» (только дни с делами) и «Доска» (колонки дней). В настройках вида — пары дня и выполненные задания.
- Календарь: месяц с отметками дней с парами и с планами, зелёные недели подсвечены; под ним лист с планами и парами выбранного дня. Лист можно поднять — месяц сворачивается до выбранной недели. - Календарь: месяц с отметками дней с парами и с планами, зелёные недели подсвечены; под ним лист с планами и парами выбранного дня. Лист можно поднять — месяц сворачивается до выбранной недели.
- Обратный отсчёт до сессии в «Обзоре»: дни до ближайшего экзамена или зачёта из `/api/exams` (консультации — только в списке), по нажатию — вся сессия. Пока университет не опубликовал сессию, отсчёт идёт до конца семестра. Экзамены кэшируются на устройстве. - Обратный отсчёт до сессии в «Обзоре»: дни до ближайшего экзамена или зачёта из `/api/exams` (консультации — только в списке), по нажатию — вся сессия. Пока университет не опубликовал сессию, отсчёт идёт до конца семестра. Экзамены кэшируются на устройстве.
- **Занятия в другом городе.** Если в Word над днём написано «Занятия в Полоцке», над парами дня появляется карточка «Занятия в Полоцке · пометка в Word на этот день» — на странице дня, в «Календаре», в «Обзоре» (сегодня и ближайший день) и на подставке «ноутбука», а в недельной сетке под датой — метка «Полоцк». Если заметка относится к одной подгруппе, а выбраны все, в карточке указана подгруппа. В карточках пар город стоит рядом с аудиторией («249Ж · Полоцк»), в подробностях — строка «Город»; так же — в виджетах, плитке и напоминаниях. Раньше такая заметка показывалась отдельной парой «Занятия в Полоцке».
- **Группа, которой больше нет.** Если сервер отвечает `group_not_found` или `document_not_found` — группу разделили («24‑ИЯ(РА)» стала «24‑ИЯ (РА)и‑1»…), или документ ушёл из каталога, — плашка с текстом сервера предлагает «Выбрать группу». Сохранённая копия остаётся на экране, а устаревшая группа убирается из недавних. - **Группа, которой больше нет.** Если сервер отвечает `group_not_found` или `document_not_found` — группу разделили («24‑ИЯ(РА)» стала «24‑ИЯ (РА)и‑1»…), или документ ушёл из каталога, — плашка с текстом сервера предлагает «Выбрать группу». Сохранённая копия остаётся на экране, а устаревшая группа убирается из недавних.
- **Изменения в расписании.** Как в PWA: когда университет перезаливает документ группы, приложение сравнивает новое расписание с копией на устройстве. В «Обзоре» появляется карточка «Расписание обновлено · N изменений» с кнопкой «Показать», а в списке видно, что изменилось, что добавлено и что удалено, например «Аудитория: 374 → 375». Карточка показывается до просмотра или закрытия, последний список остаётся в настройках («Последние изменения»). Сравнение срабатывает и при фоновом обновлении (`SyncWorker`, раз в 6 часов). Если изменения нашлись в фоне, приходит уведомление «Расписание 23-АРХ изменилось» с первыми изменениями, например «Проектирование общественных зданий: аудитория 374 → 375». Уведомление локальное, сервер push для него не нужен. Его можно выключить в настройках («Изменения в расписании», по умолчанию включено), а на Android 13+ нужно разрешение на уведомления. Считается только новая версия того же файла: смена версии парсера или переход на другой документ изменениями не считаются. Сравниваются строки Word по дням недели и разовые занятия со своей датой, а не каждая рассчитанная дата. - **Изменения в расписании.** Как в PWA: когда университет перезаливает документ группы, приложение сравнивает новое расписание с копией на устройстве. В «Обзоре» появляется карточка «Расписание обновлено · N изменений» с кнопкой «Показать», а в списке видно, что изменилось, что добавлено и что удалено, например «Аудитория: 374 → 375». Карточка показывается до просмотра или закрытия, последний список остаётся в настройках («Последние изменения»). Сравнение срабатывает и при фоновом обновлении (`SyncWorker`, раз в 6 часов). Если изменения нашлись в фоне, приходит уведомление «Расписание 23-АРХ изменилось» с первыми изменениями, например «Проектирование общественных зданий: аудитория 374 → 375». Уведомление локальное, сервер push для него не нужен. Его можно выключить в настройках («Изменения в расписании», по умолчанию включено), а на Android 13+ нужно разрешение на уведомления. Считается только новая версия того же файла: смена версии парсера или переход на другой документ изменениями не считаются. Сравниваются строки Word по дням недели и разовые занятия со своей датой, а не каждая рассчитанная дата.
- Задания и заметки к паре: из карточки занятия — к этой паре или к следующей паре того же предмета. Задания отмечаются выполненными, просроченные подсвечиваются; список — в «Обзоре», по предмету — в «Предметах», точка на блоке в недельной сетке. Хранятся только на устройстве (`notes.json`) и привязаны к группе. - Задания и заметки к паре: из карточки занятия — к этой паре или к следующей паре того же предмета. Задания отмечаются выполненными, просроченные подсвечиваются; список — в «Обзоре», по предмету — в «Предметах», точка на блоке в недельной сетке. Хранятся только на устройстве (`notes.json`) и привязаны к группе.
@@ -259,7 +268,7 @@ DOMAIN=schedule.example.by docker compose --profile https up -d --build
- **В «Планах»** они видны всегда, даже с выключенными «Парами дня». - **В «Планах»** они видны всегда, даже с выключенными «Парами дня».
- **В календаре** день с экзаменом или зачётом отмечен янтарным квадратом, а точка планов стала фиолетовой. - **В календаре** день с экзаменом или зачётом отмечен янтарным квадратом, а точка планов стала фиолетовой.
Даты считает сервер: `GET /api/timetable?doc=<id>&group=<группа>&from=ГГГГ-ММ-ДД&days=N` (до 62 дней) или с `&range=semester` — весь текущий семестр от первого до последнего дня; приложение хранит его целиком и работает с ним без сети. Ответ содержит дни с неделями, номерами пар и пометкой `uncertain` для нераспознанных пометок, плюс строки Word (`weekly`) и подгруппы. Поэтому правила белых и зелёных недель в Kotlin не дублируются. Даты считает сервер: `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): Сборка (JDK 17, Android SDK 37):
@@ -276,6 +285,7 @@ cd android
- **Онбординг:** приветствие (три стеклянные «капли» сливаются в одну), затем группа, подгруппа (только если группа делится на подгруппы) и напоминания. Разрешение на уведомления спрашивается на последнем шаге. - **Онбординг:** приветствие (три стеклянные «капли» сливаются в одну), затем группа, подгруппа (только если группа делится на подгруппы) и напоминания. Разрешение на уведомления спрашивается на последнем шаге.
- **Стеклянная панель вкладок:** три раздела — «Обзор», «Расписание», «Планы» — и поиск отдельной круглой кнопкой справа (роль `.search`). Нажатие превращает кнопку в поле ввода над клавиатурой, как в приложениях Apple (`tabViewSearchActivation(.searchTabSelection)`). «Календарь» открывается кнопкой в «Планах», «Предметы» — карточкой в «Обзоре». На iPad панель превращается в боковое меню. Группа и настройки открываются из панели «Обзора»; тема меняется сразу во всех окнах, включая открытые настройки. - **Стеклянная панель вкладок:** три раздела — «Обзор», «Расписание», «Планы» — и поиск отдельной круглой кнопкой справа (роль `.search`). Нажатие превращает кнопку в поле ввода над клавиатурой, как в приложениях Apple (`tabViewSearchActivation(.searchTabSelection)`). «Календарь» открывается кнопкой в «Планах», «Предметы» — карточкой в «Обзоре». На iPad панель превращается в боковое меню. Группа и настройки открываются из панели «Обзора»; тема меняется сразу во всех окнах, включая открытые настройки.
- **Занятия в другом городе:** как в Android и PWA. Над парами дня, в «Календаре» и в «Обзоре» (сегодня и ближайший день) стоит карточка «Занятия в Полоцке», в недельной сетке под датой — метка «Полоцк». В карточках пар город стоит рядом с аудиторией, в подробностях — строка «Город»; так же — в виджетах и напоминаниях.
- **Обзор:** неделя (сколько пар осталось, чип на каждый день), обратный отсчёт до сессии или конца семестра, следующие занятия с отсчётом «Через 20 мин», посещаемость одним касанием, задания и ближайший учебный день. На iPad рядом показывается недельная сетка. - **Обзор:** неделя (сколько пар осталось, чип на каждый день), обратный отсчёт до сессии или конца семестра, следующие занятия с отсчётом «Через 20 мин», посещаемость одним касанием, задания и ближайший учебный день. На iPad рядом показывается недельная сетка.
- **Изменения в расписании:** как в Android и PWA — карточка «Расписание обновлено · N изменений» в «Обзоре» после перезаливки документа, по «Показать» — лист с изменёнными, добавленными и удалёнными занятиями; последний список — в настройках. Сравнение срабатывает и при фоновом обновлении `BGAppRefreshTask`: тогда приходит локальное уведомление (настройка «Изменения в расписании»). Когда запускать фоновое обновление, решает iOS, поэтому уведомление может прийти через несколько часов, а если приложение смахнули из переключателя — только после следующего открытия. - **Изменения в расписании:** как в Android и PWA — карточка «Расписание обновлено · N изменений» в «Обзоре» после перезаливки документа, по «Показать» — лист с изменёнными, добавленными и удалёнными занятиями; последний список — в настройках. Сравнение срабатывает и при фоновом обновлении `BGAppRefreshTask`: тогда приходит локальное уведомление (настройка «Изменения в расписании»). Когда запускать фоновое обновление, решает iOS, поэтому уведомление может прийти через несколько часов, а если приложение смахнули из переключателя — только после следующего открытия.
- **Расписание:** дни листаются в сторону под плавающей стеклянной лентой недели. На зелёных неделях стекло окрашено зелёным. Есть недельная сетка по часам и строки Word, если семестр не найден. В подробностях пары: время по звонкам, посещаемость, задания, исходная запись Word и скрытие таких занятий. - **Расписание:** дни листаются в сторону под плавающей стеклянной лентой недели. На зелёных неделях стекло окрашено зелёным. Есть недельная сетка по часам и строки Word, если семестр не найден. В подробностях пары: время по звонкам, посещаемость, задания, исходная запись Word и скрытие таких занятий.

View File

@@ -68,6 +68,7 @@ private fun fields(a: ChangedLesson, b: ChangedLesson): List<FieldChange> {
("Вид занятия" to (a.lesson.type to b.lesson.type)), ("Вид занятия" to (a.lesson.type to b.lesson.type)),
("Преподаватель" to (a.lesson.teachers.joinToString(", ") to b.lesson.teachers.joinToString(", "))), ("Преподаватель" to (a.lesson.teachers.joinToString(", ") to b.lesson.teachers.joinToString(", "))),
("Аудитория" to (a.lesson.rooms.joinToString(", ") to b.lesson.rooms.joinToString(", "))), ("Аудитория" to (a.lesson.rooms.joinToString(", ") to b.lesson.rooms.joinToString(", "))),
("Город" to (a.lesson.place.orEmpty() to b.lesson.place.orEmpty())),
).filter { (_, values) -> values.first.trim() != values.second.trim() } ).filter { (_, values) -> values.first.trim() != values.second.trim() }
.map { (label, values) -> FieldChange(label, shown(values.first), shown(values.second)) } .map { (label, values) -> FieldChange(label, shown(values.first), shown(values.second)) }
} }

View File

@@ -40,10 +40,29 @@ data class Week(val n: Int, val color: String) {
} }
@Serializable @Serializable
data class Day(val date: String, val weekday: Int, val week: Week? = null, val lessons: List<Lesson> = emptyList()) data class Day(
val date: String,
val weekday: Int,
val week: Week? = null,
val lessons: List<Lesson> = emptyList(),
val places: List<DayPlace> = emptyList(),
)
@Serializable @Serializable
data class WeeklyDay(val day: Int, val lessons: List<Lesson> = emptyList()) data class WeeklyDay(val day: Int, val lessons: List<Lesson> = emptyList(), val places: List<DayPlace> = emptyList())
/**
* "Занятия в Полоцке" over a day in Word: that day the group's lessons (or one subgroup's,
* when `subgroup` is set) are in another town (server/timetable.js placesOn).
*/
@Serializable
data class DayPlace(val place: String, val text: String, val subgroup: String? = null)
/** The notes for the student's subgroup; a note for their own subgroup reads as the whole day's. */
fun List<DayPlace>.forSubgroup(subgroup: String?): List<DayPlace> =
filter { subgroup == null || it.subgroup == null || it.subgroup == subgroup }
.map { if (subgroup != null) it.copy(subgroup = null) else it }
.distinct()
@Serializable @Serializable
data class Lesson( data class Lesson(
@@ -56,6 +75,8 @@ data class Lesson(
val teachers: List<String> = emptyList(), val teachers: List<String> = emptyList(),
val rooms: List<String> = emptyList(), val rooms: List<String> = emptyList(),
val roomMoved: Boolean = false, val roomMoved: Boolean = false,
/** The town, when the cell or the day's note names one: "Полоцк". */
val place: String? = null,
val distance: Boolean = false, val distance: Boolean = false,
val condition: String = "", val condition: String = "",
val variant: String = "all", val variant: String = "all",
@@ -68,6 +89,10 @@ data class Lesson(
/** A recurring lesson the student may hide: the subject with its kind, in every week. */ /** A recurring lesson the student may hide: the subject with its kind, in every week. */
val Lesson.hideKey: String get() = subject.trim().lowercase() + "|" + type.trim().lowercase() val Lesson.hideKey: String get() = subject.trim().lowercase() + "|" + type.trim().lowercase()
/** A room label with the lesson's town when the document names one: "ауд. 203Г · Полоцк", or the town alone. */
fun withPlace(room: String?, place: String?): String? =
listOfNotNull(room?.takeIf { it.isNotEmpty() }, place).joinToString(" · ").ifEmpty { null }
@Serializable @Serializable
data class GroupsResponse( data class GroupsResponse(
val results: List<GroupResult> = emptyList(), val results: List<GroupResult> = emptyList(),
@@ -118,6 +143,7 @@ data class DirectoryLesson(
val teachers: List<String> = emptyList(), val teachers: List<String> = emptyList(),
val rooms: List<String> = emptyList(), val rooms: List<String> = emptyList(),
val moved: Boolean = false, val moved: Boolean = false,
val place: String? = null,
val distance: Boolean = false, val distance: Boolean = false,
val groups: List<String> = emptyList(), val groups: List<String> = emptyList(),
val condition: String = "", val condition: String = "",

View File

@@ -114,4 +114,14 @@ class Repository(context: Context, val api: Api, private val settings: Settings)
fun dayOf(timetable: Timetable, date: LocalDate): Day? = fun dayOf(timetable: Timetable, date: LocalDate): Day? =
timetable.days.firstOrNull { it.date == date.toString() } timetable.days.firstOrNull { it.date == date.toString() }
/**
* "Занятия в Полоцке" on the date for the student's subgroup, and only on a day they have
* lessons. A note for their own subgroup reads as the whole day's.
*/
fun placesOn(timetable: Timetable, date: LocalDate, subgroup: String?, hidden: Set<String> = emptySet()): List<DayPlace> {
val day = dayOf(timetable, date) ?: return emptyList()
if (day.places.isEmpty() || lessonsOn(timetable, date, subgroup, hidden).isEmpty()) return emptyList()
return day.places.forSubgroup(subgroup)
}
} }

View File

@@ -1,6 +1,7 @@
package org.xtimms.psu.schedule.notify package org.xtimms.psu.schedule.notify
import org.xtimms.psu.schedule.data.hideKey import org.xtimms.psu.schedule.data.hideKey
import org.xtimms.psu.schedule.data.withPlace
import android.Manifest import android.Manifest
import android.app.AlarmManager import android.app.AlarmManager
import android.app.NotificationChannel import android.app.NotificationChannel
@@ -85,11 +86,14 @@ object Reminders {
if (at.isBefore(now) || begins.isAfter(horizon) || slot >= MAX) continue if (at.isBefore(now) || begins.isAfter(horizon) || slot >= MAX) continue
val first = lessons.first() val first = lessons.first()
val title = if (lessons.size == 1) first.subject else lessons.joinToString(" / ") { it.subject } val title = if (lessons.size == 1) first.subject else lessons.joinToString(" / ") { it.subject }
val place = lessons.flatMap { it.rooms }.distinct().joinToString(", ") val place = withPlace(
lessons.flatMap { it.rooms }.distinct().joinToString(", ").takeIf { it.isNotEmpty() }?.let { "ауд. $it" },
lessons.firstNotNullOfOrNull { it.place },
)
val text = buildString { val text = buildString {
append(start) append(start)
first.pair?.let { append(" · $it пара") } first.pair?.let { append(" · $it пара") }
if (place.isNotEmpty()) append(" · ауд. $place") place?.let { append(" · $it") }
if (lessons.any { it.uncertain }) append(" · сверьте недели") if (lessons.any { it.uncertain }) append(" · сверьте недели")
} }
val pending = intent(context, slot) { val pending = intent(context, slot) {

View File

@@ -32,7 +32,7 @@ class LessonTileService : TileService() {
tile.state = Tile.STATE_INACTIVE tile.state = Tile.STATE_INACTIVE
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) tile.subtitle = "на ближайшую неделю" if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) tile.subtitle = "на ближайшую неделю"
} else { } else {
val room = next.rooms.takeIf { it.isNotEmpty() }?.let { "ауд. $it" } val room = next.where
tile.label = listOfNotNull(next.whenLabel(), room).joinToString(" · ") tile.label = listOfNotNull(next.whenLabel(), room).joinToString(" · ")
tile.state = if (next.running) Tile.STATE_ACTIVE else Tile.STATE_INACTIVE tile.state = if (next.running) Tile.STATE_ACTIVE else Tile.STATE_INACTIVE
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) tile.subtitle = next.subject if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) tile.subtitle = next.subject

View File

@@ -95,6 +95,7 @@ import org.xtimms.psu.schedule.data.longLabel
import org.xtimms.psu.schedule.data.monday import org.xtimms.psu.schedule.data.monday
import org.xtimms.psu.schedule.data.today import org.xtimms.psu.schedule.data.today
import org.xtimms.psu.schedule.data.weekdayName import org.xtimms.psu.schedule.data.weekdayName
import org.xtimms.psu.schedule.data.withPlace
import org.xtimms.psu.schedule.ui.theme.page import org.xtimms.psu.schedule.ui.theme.page
import java.time.LocalDate import java.time.LocalDate
import java.time.YearMonth import java.time.YearMonth
@@ -468,6 +469,7 @@ private fun DayAgendaContent(
val plans = notes.filter { it.date == date.toString() }.sortedWith(compareBy({ it.task && it.done }, { it.start })) val plans = notes.filter { it.date == date.toString() }.sortedWith(compareBy({ it.task && it.done }, { it.start }))
val known = t?.days?.any { it.date == date.toString() } == true val known = t?.days?.any { it.date == date.toString() } == true
val slots = if (t != null && known) vm.slotsOn(t, date, subgroup) else emptyList() val slots = if (t != null && known) vm.slotsOn(t, date, subgroup) else emptyList()
val places = if (t != null && known) vm.placesOn(t, date, subgroup) else emptyList()
val lessons = slots.flatMap { slot -> slot.lessons.map { slot to it } } val lessons = slots.flatMap { slot -> slot.lessons.map { slot to it } }
if (plans.isEmpty() && lessons.isEmpty()) { if (plans.isEmpty() && lessons.isEmpty()) {
@@ -519,6 +521,7 @@ private fun DayAgendaContent(
item(key = "lessons") { AnimatedItem { item(key = "lessons") { AnimatedItem {
Section(number, "Ваши занятия", trailing = if (number == 1) date.format(DATE) else null, gap = number > 1) Section(number, "Ваши занятия", trailing = if (number == 1) date.format(DATE) else null, gap = number > 1)
} } } }
if (places.isNotEmpty()) item(key = "places") { AnimatedItem { DayPlaces(places, Modifier.padding(top = 8.dp, bottom = 12.dp)) } }
lessons.forEachIndexed { i, (slot, l) -> lessons.forEachIndexed { i, (slot, l) ->
item(key = "${slot.start}|${l.key}") { item(key = "${slot.start}|${l.key}") {
val color = subjectColor(l.subject) val color = subjectColor(l.subject)
@@ -532,7 +535,7 @@ private fun DayAgendaContent(
overColor = if (session != null) sessionColors().accent else null, overColor = if (session != null) sessionColors().accent else null,
title = l.subject, title = l.subject,
under = listOfNotNull( under = listOfNotNull(
if (l.distance) "дистанционно" else l.rooms.joinToString(", ").ifEmpty { null }?.let { "ауд. $it" }, if (l.distance) "дистанционно" else withPlace(l.rooms.joinToString(", ").ifEmpty { null }?.let { "ауд. $it" }, l.place),
l.type.ifEmpty { null }?.takeIf { session == null }?.lowercase(), l.type.ifEmpty { null }?.takeIf { session == null }?.lowercase(),
).joinToString(" · ").ifEmpty { null }, ).joinToString(" · ").ifEmpty { null },
last = i == lessons.lastIndex, last = i == lessons.lastIndex,

View File

@@ -36,6 +36,7 @@ import org.xtimms.psu.schedule.data.longLabel
import org.xtimms.psu.schedule.data.parseDate import org.xtimms.psu.schedule.data.parseDate
import org.xtimms.psu.schedule.data.plural import org.xtimms.psu.schedule.data.plural
import org.xtimms.psu.schedule.data.shortWeekday import org.xtimms.psu.schedule.data.shortWeekday
import org.xtimms.psu.schedule.data.withPlace
import org.xtimms.psu.schedule.ui.theme.card import org.xtimms.psu.schedule.ui.theme.card
import java.time.Instant import java.time.Instant
import java.time.ZonedDateTime import java.time.ZonedDateTime
@@ -106,7 +107,7 @@ fun ChangesSheet(record: ScheduleChanges) {
if (record.added.isNotEmpty()) { if (record.added.isNotEmpty()) {
Heading("Добавлено · ${record.added.size}", modifier = Modifier.padding(top = 8.dp)) Heading("Добавлено · ${record.added.size}", modifier = Modifier.padding(top = 8.dp))
record.added.forEach { row -> record.added.forEach { row ->
ChangeItem(row, details = listOf(row.lesson.type, row.lesson.teachers.joinToString(", "), row.lesson.rooms.joinToString(", ") { "ауд. $it" }, row.lesson.condition)) ChangeItem(row, details = listOf(row.lesson.type, row.lesson.teachers.joinToString(", "), withPlace(row.lesson.rooms.joinToString(", ") { "ауд. $it" }, row.lesson.place).orEmpty(), row.lesson.condition))
} }
} }
if (record.removed.isNotEmpty()) { if (record.removed.isNotEmpty()) {

View File

@@ -86,6 +86,7 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import org.xtimms.psu.schedule.data.DayPlace
import org.xtimms.psu.schedule.data.Lesson import org.xtimms.psu.schedule.data.Lesson
import org.xtimms.psu.schedule.data.Timetable import org.xtimms.psu.schedule.data.Timetable
import org.xtimms.psu.schedule.data.longLabel import org.xtimms.psu.schedule.data.longLabel
@@ -97,6 +98,7 @@ import org.xtimms.psu.schedule.data.plural
import org.xtimms.psu.schedule.data.today import org.xtimms.psu.schedule.data.today
import org.xtimms.psu.schedule.data.weekSpan import org.xtimms.psu.schedule.data.weekSpan
import org.xtimms.psu.schedule.data.weekdayName import org.xtimms.psu.schedule.data.weekdayName
import org.xtimms.psu.schedule.data.withPlace
import java.time.Duration import java.time.Duration
import java.time.LocalDate import java.time.LocalDate
import java.time.LocalTime import java.time.LocalTime
@@ -226,7 +228,7 @@ fun HomeScreen(vm: MainViewModel, nav: AppNav) {
item(key = "week") { AnimatedItem { WeekReport(vm, t, subgroup, date, clock) } } item(key = "week") { AnimatedItem { WeekReport(vm, t, subgroup, date, clock) } }
item(key = "session") { AnimatedItem { SessionCard(vm, t, onOpen = { session = true }) } } item(key = "session") { AnimatedItem { SessionCard(vm, t, onOpen = { session = true }) } }
item(key = "today-title") { AnimatedItem { DayTitle("Сегодня", date) } } item(key = "today-title") { AnimatedItem { DayTitle("Сегодня", date) } }
item(key = "today") { AnimatedItem { TodayCard(todaySlots, clock, onOpen = { details = it to date }, onDay = { nav.openDay(date) }) } } item(key = "today") { AnimatedItem { TodayCard(todaySlots, vm.placesOn(t, date, subgroup), clock, onOpen = { details = it to date }, onDay = { nav.openDay(date) }) } }
item(key = "attendance") { AnimatedItem { AttendanceCard(vm, todaySlots) } } item(key = "attendance") { AnimatedItem { AttendanceCard(vm, todaySlots) } }
item(key = "tasks") { AnimatedItem { TasksCard(vm, onEdit = { editing = it }, onAll = nav.openPlans) } } item(key = "tasks") { AnimatedItem { TasksCard(vm, onEdit = { editing = it }, onAll = nav.openPlans) } }
if (next != null) { if (next != null) {
@@ -234,6 +236,7 @@ fun HomeScreen(vm: MainViewModel, nav: AppNav) {
item(key = "next-title") { AnimatedItem { DayTitle(if (day == date.plusDays(1)) "Завтра" else day.weekdayName(), day) } } item(key = "next-title") { AnimatedItem { DayTitle(if (day == date.plusDays(1)) "Завтра" else day.weekdayName(), day) } }
item(key = "next") { item(key = "next") {
SectionCard(Icons.Rounded.EventAvailable, plural(slots.size, "пара", "пары", "пар").let { "${slots.size} $it" }) { SectionCard(Icons.Rounded.EventAvailable, plural(slots.size, "пара", "пары", "пар").let { "${slots.size} $it" }) {
DayPlaces(vm.placesOn(t, day, subgroup))
slots.forEach { slot -> slot.lessons.forEach { CompactLesson(it, slot, onClick = { details = it to day }) } } slots.forEach { slot -> slot.lessons.forEach { CompactLesson(it, slot, onClick = { details = it to day }) } }
DayButton { nav.openDay(day) } DayButton { nav.openDay(day) }
} }
@@ -444,7 +447,7 @@ private fun DayTitle(label: String, date: LocalDate) {
} }
@Composable @Composable
private fun TodayCard(slots: List<Slot>, clock: LocalTime, onOpen: (Lesson) -> Unit, onDay: () -> Unit) { private fun TodayCard(slots: List<Slot>, places: List<DayPlace>, clock: LocalTime, onOpen: (Lesson) -> Unit, onDay: () -> Unit) {
val left = slots.filter { it.end.isAfter(clock) } val left = slots.filter { it.end.isAfter(clock) }
SectionCard(Icons.Rounded.Schedule, "Следующие занятия") { SectionCard(Icons.Rounded.Schedule, "Следующие занятия") {
when { when {
@@ -454,6 +457,7 @@ private fun TodayCard(slots: List<Slot>, clock: LocalTime, onOpen: (Lesson) -> U
"Сегодня было ${slots.size} ${plural(slots.size, "пара", "пары", "пар")}.", "Сегодня было ${slots.size} ${plural(slots.size, "пара", "пары", "пар")}.",
) )
else -> { else -> {
DayPlaces(places)
val first = left.first() val first = left.first()
first.lessons.forEach { FeaturedLesson(it, first, clock, onClick = { onOpen(it) }) } first.lessons.forEach { FeaturedLesson(it, first, clock, onClick = { onOpen(it) }) }
if (left.size > 1) HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) if (left.size > 1) HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
@@ -541,7 +545,7 @@ private fun FeaturedLesson(lesson: Lesson, slot: Slot, clock: LocalTime, onClick
} }
} }
val show = LocalDisplay.current val show = LocalDisplay.current
val room = if (lesson.distance) "Дистанционно" else lesson.rooms.joinToString(", ").takeIf { show.rooms && it.isNotEmpty() }?.let { "Ауд. $it" } val room = if (lesson.distance) "Дистанционно" else withPlace(lesson.rooms.joinToString(", ").takeIf { show.rooms && it.isNotEmpty() }?.let { "Ауд. $it" }, lesson.place)
room?.let { HomeMeta(Icons.Rounded.LocationOn, it) } room?.let { HomeMeta(Icons.Rounded.LocationOn, it) }
if (show.teachers && lesson.teachers.isNotEmpty()) HomeMeta(Icons.Rounded.Person, lesson.teachers.joinToString(", ")) if (show.teachers && lesson.teachers.isNotEmpty()) HomeMeta(Icons.Rounded.Person, lesson.teachers.joinToString(", "))
} }
@@ -570,7 +574,7 @@ fun CompactLesson(lesson: Lesson, slot: Slot, onClick: (() -> Unit)?) {
listOfNotNull( listOfNotNull(
session?.label, session?.label,
slot.timeLabel(), slot.timeLabel(),
if (lesson.distance) "дистанционно" else lesson.rooms.joinToString(", ").takeIf { show.rooms && it.isNotEmpty() }?.let { "ауд. $it" }, if (lesson.distance) "дистанционно" else withPlace(lesson.rooms.joinToString(", ").takeIf { show.rooms && it.isNotEmpty() }?.let { "ауд. $it" }, lesson.place),
lesson.type.takeIf { show.tags && it.isNotEmpty() && session == null }?.lowercase(), lesson.type.takeIf { show.tags && it.isNotEmpty() && session == null }?.lowercase(),
).joinToString(" · "), ).joinToString(" · "),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,

View File

@@ -0,0 +1,80 @@
package org.xtimms.psu.schedule.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.LocationCity
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import org.xtimms.psu.schedule.data.DayPlace
import org.xtimms.psu.schedule.data.Timetable
import java.time.LocalDate
// "Занятия в Полоцке": Word marks the days a group spends in another town over the day's first
// row. The server reads it as a note of the day, not a lesson, and gives each lesson its town.
fun MainViewModel.placesOn(t: Timetable, date: LocalDate, subgroup: String?): List<DayPlace> =
repository.placesOn(t, date, subgroup, hidden)
/** The day's note above its lessons: the town in a tertiary card, with the subgroup it is for. */
@Composable
fun DayPlaces(places: List<DayPlace>, modifier: Modifier = Modifier) {
if (places.isEmpty()) return
Surface(
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.tertiaryContainer,
contentColor = MaterialTheme.colorScheme.onTertiaryContainer,
modifier = modifier.fillMaxWidth(),
) {
Column(Modifier.padding(horizontal = 16.dp, vertical = 12.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
places.forEach { p ->
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Rounded.LocationCity, contentDescription = null, modifier = Modifier.size(24.dp))
Spacer(Modifier.width(14.dp))
Column {
Text(p.text, style = MaterialTheme.typography.titleMediumEmphasized)
Text(
listOfNotNull(p.subgroup, "пометка в Word на этот день").joinToString(" · ").replaceFirstChar { it.titlecase() },
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
}
}
}
/** The town under a day's date in the week grid: short, in the note's colour. */
@Composable
fun DayPlaceMark(places: List<DayPlace>, modifier: Modifier = Modifier) {
val first = places.firstOrNull() ?: return
Surface(
shape = MaterialTheme.shapes.extraSmall,
color = MaterialTheme.colorScheme.tertiaryContainer,
contentColor = MaterialTheme.colorScheme.onTertiaryContainer,
modifier = modifier.semantics { contentDescription = places.joinToString(", ") { listOfNotNull(it.text, it.subgroup).joinToString(", ") } },
) {
Text(
first.place,
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(horizontal = 4.dp, vertical = 1.dp),
)
}
}

View File

@@ -69,6 +69,7 @@ import androidx.compose.material.icons.rounded.ErrorOutline
import androidx.compose.material.icons.rounded.EventBusy import androidx.compose.material.icons.rounded.EventBusy
import androidx.compose.material.icons.rounded.EventRepeat import androidx.compose.material.icons.rounded.EventRepeat
import androidx.compose.material.icons.rounded.Groups import androidx.compose.material.icons.rounded.Groups
import androidx.compose.material.icons.rounded.LocationCity
import androidx.compose.material.icons.rounded.LocationOn import androidx.compose.material.icons.rounded.LocationOn
import androidx.compose.material.icons.rounded.Person import androidx.compose.material.icons.rounded.Person
import androidx.compose.material.icons.rounded.Refresh import androidx.compose.material.icons.rounded.Refresh
@@ -101,13 +102,16 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.xtimms.psu.schedule.data.Bell import org.xtimms.psu.schedule.data.Bell
import org.xtimms.psu.schedule.data.DayPlace
import org.xtimms.psu.schedule.data.Lesson import org.xtimms.psu.schedule.data.Lesson
import org.xtimms.psu.schedule.data.Timetable import org.xtimms.psu.schedule.data.Timetable
import org.xtimms.psu.schedule.data.dayName import org.xtimms.psu.schedule.data.dayName
import org.xtimms.psu.schedule.data.forSubgroup
import org.xtimms.psu.schedule.data.minutesLabel import org.xtimms.psu.schedule.data.minutesLabel
import org.xtimms.psu.schedule.data.monday import org.xtimms.psu.schedule.data.monday
import org.xtimms.psu.schedule.data.parseDate import org.xtimms.psu.schedule.data.parseDate
import org.xtimms.psu.schedule.data.today import org.xtimms.psu.schedule.data.today
import org.xtimms.psu.schedule.data.withPlace
import java.time.Duration import java.time.Duration
import java.time.LocalDate import java.time.LocalDate
@@ -384,6 +388,7 @@ private fun Planner(
DayPage( DayPage(
date = pageDate, date = pageDate,
slots = vm.slotsOn(t, pageDate, subgroup), slots = vm.slotsOn(t, pageDate, subgroup),
places = vm.placesOn(t, pageDate, subgroup),
onOpen = onOpen, onOpen = onOpen,
) )
} }
@@ -402,6 +407,7 @@ private fun Planner(
date = pageDate, date = pageDate,
week = t.days.firstOrNull { it.date == pageDate.toString() }?.week, week = t.days.firstOrNull { it.date == pageDate.toString() }?.week,
slots = vm.slotsOn(t, pageDate, subgroup), slots = vm.slotsOn(t, pageDate, subgroup),
places = vm.placesOn(t, pageDate, subgroup),
chosen = stand?.takeIf { it.first == pageDate }?.second, chosen = stand?.takeIf { it.first == pageDate }?.second,
clock = clock, clock = clock,
onOpen = { onOpen(it, pageDate) }, onOpen = { onOpen(it, pageDate) },
@@ -431,7 +437,7 @@ private fun Planner(
} }
@Composable @Composable
private fun DayPage(date: LocalDate, slots: List<Slot>, onOpen: (Lesson, LocalDate) -> Unit) { private fun DayPage(date: LocalDate, slots: List<Slot>, places: List<DayPlace>, onOpen: (Lesson, LocalDate) -> Unit) {
val clock = rememberClock() val clock = rememberClock()
val isToday = date == today() val isToday = date == today()
val next = if (isToday) slots.firstOrNull { it.start.isAfter(clock) } else null val next = if (isToday) slots.firstOrNull { it.start.isAfter(clock) } else null
@@ -446,6 +452,8 @@ private fun DayPage(date: LocalDate, slots: List<Slot>, onOpen: (Lesson, LocalDa
Empty("Занятий нет", "По таблице недель в этот день свободно — можно отдохнуть.", icon = Icons.Rounded.Weekend) Empty("Занятий нет", "По таблице недель в этот день свободно — можно отдохнуть.", icon = Icons.Rounded.Weekend)
} }
} }
// "Занятия в Полоцке": the day's town before its lessons.
if (places.isNotEmpty()) item(key = "places") { AnimatedItem { DayPlaces(places) } }
slots.forEach { slot -> slots.forEach { slot ->
val running = isToday && !clock.isBefore(slot.start) && clock.isBefore(slot.end) val running = isToday && !clock.isBefore(slot.start) && clock.isBefore(slot.end)
val status = when { val status = when {
@@ -508,10 +516,10 @@ fun LessonCard(
time?.let { TimeText(it + (pair?.takeIf { show.pairs }?.let { p -> " · $p пара" } ?: ""), style = MaterialTheme.typography.bodyLarge) } time?.let { TimeText(it + (pair?.takeIf { show.pairs }?.let { p -> " · $p пара" } ?: ""), style = MaterialTheme.typography.bodyLarge) }
FlowRow(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { FlowRow(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
if (lesson.distance) Meta(Icons.Rounded.LocationOn, "Дистанционно") if (lesson.distance) Meta(Icons.Rounded.LocationOn, "Дистанционно")
else if (show.rooms && lesson.rooms.isNotEmpty()) Meta( else withPlace(
Icons.Rounded.LocationOn, lesson.rooms.takeIf { show.rooms && it.isNotEmpty() }?.let { it.joinToString(", ") + if (lesson.roomMoved) " (перенос)" else "" },
lesson.rooms.joinToString(", ") + if (lesson.roomMoved) " (перенос)" else "", lesson.place,
) )?.let { Meta(Icons.Rounded.LocationOn, it) }
if (show.teachers && lesson.teachers.isNotEmpty()) Meta(Icons.Rounded.Person, lesson.teachers.joinToString(", ")) if (show.teachers && lesson.teachers.isNotEmpty()) Meta(Icons.Rounded.Person, lesson.teachers.joinToString(", "))
lesson.subgroup?.let { Meta(Icons.Rounded.Groups, it) } lesson.subgroup?.let { Meta(Icons.Rounded.Groups, it) }
} }
@@ -644,6 +652,7 @@ internal fun LessonDetails(
if (lesson.distance) "Дистанционно" else lesson.rooms.joinToString(", ").ifEmpty { "Не указана" }, if (lesson.distance) "Дистанционно" else lesson.rooms.joinToString(", ").ifEmpty { "Не указана" },
if (lesson.roomMoved) "В этот день — перенос аудитории" else null, if (lesson.roomMoved) "В этот день — перенос аудитории" else null,
) )
lesson.place?.let { Detail(Icons.Rounded.LocationCity, "Город", it, "Так указано в Word") }
Detail(Icons.Rounded.Groups, "Группы", lesson.groups.joinToString(", "), lesson.subgroup) Detail(Icons.Rounded.Groups, "Группы", lesson.groups.joinToString(", "), lesson.subgroup)
if (lesson.uncertain) Banner( if (lesson.uncertain) Banner(
"Пометку «${lesson.condition}» не удалось сопоставить с таблицей недель. Сверьте с Word.", "Пометку «${lesson.condition}» не удалось сопоставить с таблицей недель. Сверьте с Word.",
@@ -702,6 +711,8 @@ private fun WordRows(t: Timetable, subgroup: String?, hidden: Set<String>, onOpe
} }
val lessons = day.lessons.filter { subgroup == null || it.subgroup == null || it.subgroup == subgroup } val lessons = day.lessons.filter { subgroup == null || it.subgroup == null || it.subgroup == subgroup }
.filter { it.hideKey !in hidden } .filter { it.hideKey !in hidden }
val places = if (lessons.isEmpty()) emptyList() else day.places.forSubgroup(subgroup)
if (places.isNotEmpty()) item(key = "p${day.day}") { DayPlaces(places) }
items(lessons, key = { "${day.day}-${it.key}" }) { lesson -> items(lessons, key = { "${day.day}-${it.key}" }) { lesson ->
LessonCard(lesson, time = lesson.start + (lesson.end?.let { " → $it" } ?: ""), onClick = { onOpen(lesson) }) LessonCard(lesson, time = lesson.start + (lesson.end?.let { " → $it" } ?: ""), onClick = { onOpen(lesson) })
} }

View File

@@ -261,6 +261,7 @@ fun SearchScreen(vm: MainViewModel, nav: AppNav) {
teachers = if (target is Target.Room) l.teachers else emptyList(), teachers = if (target is Target.Room) l.teachers else emptyList(),
rooms = if (target is Target.Teacher) l.rooms else emptyList(), rooms = if (target is Target.Teacher) l.rooms else emptyList(),
roomMoved = l.moved, roomMoved = l.moved,
place = l.place,
distance = l.distance, distance = l.distance,
condition = l.condition, condition = l.condition,
groups = l.groups, groups = l.groups,

View File

@@ -12,6 +12,7 @@ import androidx.compose.material.icons.automirrored.rounded.Assignment
import androidx.compose.material.icons.rounded.Add import androidx.compose.material.icons.rounded.Add
import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.FilledTonalButton
import org.xtimms.psu.schedule.data.Note import org.xtimms.psu.schedule.data.Note
import org.xtimms.psu.schedule.data.withPlace
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.material.icons.rounded.DateRange import androidx.compose.material.icons.rounded.DateRange
import androidx.compose.material.icons.rounded.Event import androidx.compose.material.icons.rounded.Event
@@ -283,7 +284,7 @@ private fun SubjectCard(s: SubjectSummary, onClick: () -> Unit) {
Fact( Fact(
Icons.Rounded.Event, Icons.Rounded.Event,
next?.let { (slot, lesson) -> next?.let { (slot, lesson) ->
nextLabel(slot) + (lesson.rooms.takeIf { show.rooms && it.isNotEmpty() }?.let { ", ауд. ${it.joinToString(", ")}" } ?: "") nextLabel(slot) + (withPlace(lesson.rooms.takeIf { show.rooms && it.isNotEmpty() }?.let { "ауд. ${it.joinToString(", ")}" }, lesson.place)?.let { ", $it" } ?: "")
} ?: "В загруженных неделях больше нет", } ?: "В загруженных неделях больше нет",
strong = next != null, strong = next != null,
) )
@@ -360,7 +361,7 @@ private fun SubjectDetails(s: SubjectSummary, vm: MainViewModel) {
Text( Text(
listOfNotNull( listOfNotNull(
lesson.type.ifEmpty { null }, lesson.type.ifEmpty { null },
if (lesson.distance) "дистанционно" else lesson.rooms.joinToString(", ").ifEmpty { null }?.let { "ауд. $it" }, if (lesson.distance) "дистанционно" else withPlace(lesson.rooms.joinToString(", ").ifEmpty { null }?.let { "ауд. $it" }, lesson.place),
lesson.teachers.joinToString(", ").ifEmpty { null }, lesson.teachers.joinToString(", ").ifEmpty { null },
).joinToString(" · "), ).joinToString(" · "),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,

View File

@@ -37,7 +37,9 @@ import androidx.compose.ui.semantics.selected
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import org.xtimms.psu.schedule.data.DayPlace
import org.xtimms.psu.schedule.data.Lesson import org.xtimms.psu.schedule.data.Lesson
import org.xtimms.psu.schedule.data.withPlace
import org.xtimms.psu.schedule.data.Week import org.xtimms.psu.schedule.data.Week
import org.xtimms.psu.schedule.data.longLabel import org.xtimms.psu.schedule.data.longLabel
import org.xtimms.psu.schedule.data.minutesLabel import org.xtimms.psu.schedule.data.minutesLabel
@@ -65,6 +67,7 @@ fun StandDay(
date: LocalDate, date: LocalDate,
week: Week?, week: Week?,
slots: List<Slot>, slots: List<Slot>,
places: List<DayPlace>,
chosen: LocalTime?, chosen: LocalTime?,
clock: LocalTime, clock: LocalTime,
onOpen: (Lesson) -> Unit, onOpen: (Lesson) -> Unit,
@@ -86,7 +89,11 @@ fun StandDay(
} }
Text( Text(
if (slots.isEmpty()) "Пар нет" if (slots.isEmpty()) "Пар нет"
else "${slots.size} ${plural(slots.size, "пара", "пары", "пар")} · ${slots.first().start}–${slots.last().end}", else listOfNotNull(
"${slots.size} ${plural(slots.size, "пара", "пары", "пар")} · ${slots.first().start}–${slots.last().end}",
// "Занятия в Полоцке", readable from across the desk with the day's span.
places.joinToString(", ") { listOfNotNull(it.text, it.subgroup?.lowercase()).joinToString(", ") }.ifEmpty { null },
).joinToString(" · "),
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 12.dp), modifier = Modifier.padding(bottom = 12.dp),
@@ -158,14 +165,14 @@ private fun StandLesson(slot: Slot, clock: LocalTime, isToday: Boolean, onOpen:
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
Row(horizontalArrangement = Arrangement.spacedBy(20.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(20.dp)) {
val room = if (lesson.distance) "Дистанционно" else lesson.rooms.joinToString(", ").takeIf { show.rooms && it.isNotEmpty() }?.let { "Ауд. $it" } val room = if (lesson.distance) "Дистанционно" else withPlace(lesson.rooms.joinToString(", ").takeIf { show.rooms && it.isNotEmpty() }?.let { "Ауд. $it" }, lesson.place)
room?.let { StandMeta(Icons.Rounded.LocationOn, it) } room?.let { StandMeta(Icons.Rounded.LocationOn, it) }
if (show.teachers && lesson.teachers.isNotEmpty()) StandMeta(Icons.Rounded.Person, lesson.teachers.joinToString(", ")) if (show.teachers && lesson.teachers.isNotEmpty()) StandMeta(Icons.Rounded.Person, lesson.teachers.joinToString(", "))
} }
// Parallel subgroups share the slot: the others in a line. // Parallel subgroups share the slot: the others in a line.
slot.lessons.drop(1).forEach { other -> slot.lessons.drop(1).forEach { other ->
Text( Text(
listOfNotNull(other.subgroup, other.subject, other.rooms.joinToString(", ").takeIf { it.isNotEmpty() }?.let { "ауд. $it" }) listOfNotNull(other.subgroup, other.subject, withPlace(other.rooms.joinToString(", ").takeIf { it.isNotEmpty() }?.let { "ауд. $it" }, other.place))
.joinToString(" · "), .joinToString(" · "),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
maxLines = 1, maxLines = 1,
@@ -271,7 +278,8 @@ fun StandLessons(
color = if (past && !selected) MaterialTheme.colorScheme.onSurfaceVariant else Color.Unspecified, color = if (past && !selected) MaterialTheme.colorScheme.onSurfaceVariant else Color.Unspecified,
) )
val room = slot.lessons.flatMap { it.rooms }.distinct().joinToString(", ") val room = slot.lessons.flatMap { it.rooms }.distinct().joinToString(", ")
if (room.isNotEmpty()) Text("ауд. $room", style = MaterialTheme.typography.bodySmall, maxLines = 1) withPlace(room.takeIf { it.isNotEmpty() }?.let { "ауд. $it" }, slot.lessons.firstNotNullOfOrNull { it.place })
?.let { Text(it, style = MaterialTheme.typography.bodySmall, maxLines = 1) }
} }
} }
} }

View File

@@ -45,6 +45,7 @@ import org.xtimms.psu.schedule.data.longLabel
import org.xtimms.psu.schedule.data.shortWeekday import org.xtimms.psu.schedule.data.shortWeekday
import org.xtimms.psu.schedule.data.today import org.xtimms.psu.schedule.data.today
import org.xtimms.psu.schedule.data.weekdayName import org.xtimms.psu.schedule.data.weekdayName
import org.xtimms.psu.schedule.data.withPlace
import java.time.LocalDate import java.time.LocalDate
import java.time.LocalTime import java.time.LocalTime
@@ -88,6 +89,7 @@ fun WeekGrid(
) { ) {
days.forEach { (d, _) -> days.forEach { (d, _) ->
val isToday = d == today() val isToday = d == today()
val places = vm.placesOn(t, d, subgroup)
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier modifier = Modifier
@@ -113,6 +115,8 @@ fun WeekGrid(
color = if (isToday) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface, color = if (isToday) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface,
) )
} }
// "Занятия в Полоцке": the town of the whole day, under its date.
DayPlaceMark(places, Modifier.padding(top = 2.dp, start = 2.dp, end = 2.dp))
} }
} }
} }
@@ -175,10 +179,12 @@ fun WeekGrid(
private fun LessonBlock(lesson: Lesson, slot: Slot, wide: Boolean, notes: Int, onClick: () -> Unit, modifier: Modifier) { private fun LessonBlock(lesson: Lesson, slot: Slot, wide: Boolean, notes: Int, onClick: () -> Unit, modifier: Modifier) {
val color = subjectColor(lesson.subject) val color = subjectColor(lesson.subject)
val session = lesson.session val session = lesson.session
val rooms = lesson.rooms.joinToString(", ").takeIf { LocalDisplay.current.rooms }.orEmpty()
// A narrow column keeps the room whole: the day's town is under its date.
val room = when { val room = when {
lesson.distance -> "дист." lesson.distance -> "дист."
LocalDisplay.current.rooms -> lesson.rooms.joinToString(", ") wide -> withPlace(rooms, lesson.place).orEmpty()
else -> "" else -> rooms.ifEmpty { lesson.place.orEmpty() }
} }
Surface( Surface(
onClick = onClick, onClick = onClick,

View File

@@ -69,11 +69,13 @@ class NextLessonWidget : GlanceAppWidget() {
maxLines = 1, maxLines = 1,
style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 15.sp, fontWeight = FontWeight.Bold), style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 15.sp, fontWeight = FontWeight.Bold),
) )
if (next.rooms.isNotEmpty()) Text( next.where?.let { where ->
"ауд. ${next.rooms}", Text(
maxLines = 1, where,
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 13.sp), maxLines = 1,
) style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 13.sp),
)
}
} }
} }
} }

View File

@@ -34,6 +34,7 @@ import org.xtimms.psu.schedule.data.nowTime
import org.xtimms.psu.schedule.data.parseTime import org.xtimms.psu.schedule.data.parseTime
import org.xtimms.psu.schedule.data.today import org.xtimms.psu.schedule.data.today
import org.xtimms.psu.schedule.data.weekdayName import org.xtimms.psu.schedule.data.weekdayName
import org.xtimms.psu.schedule.data.withPlace
import java.time.Duration import java.time.Duration
import java.time.LocalDate import java.time.LocalDate
@@ -71,8 +72,11 @@ private suspend fun state(context: Context): WidgetState {
"$start · через ${minutesLabel(Duration.between(now, begins).toMinutes())}" "$start · через ${minutesLabel(Duration.between(now, begins).toMinutes())}"
else -> start + (l.pair?.let { " · $it пара" } ?: "") else -> start + (l.pair?.let { " · $it пара" } ?: "")
} }
val place = lessons.flatMap { it.rooms }.distinct().joinToString(", ") val place = withPlace(
label to (lessons.joinToString(" / ") { it.subject } + if (place.isNotEmpty()) " · ауд. $place" else "") lessons.flatMap { it.rooms }.distinct().joinToString(", ").takeIf { it.isNotEmpty() }?.let { "ауд. $it" },
lessons.firstNotNullOfOrNull { it.place },
)
label to (lessons.joinToString(" / ") { it.subject } + (place?.let { " · $it" } ?: ""))
} }
return WidgetState( return WidgetState(
timetable.group, timetable.group,

View File

@@ -8,6 +8,7 @@ import org.xtimms.psu.schedule.data.nowTime
import org.xtimms.psu.schedule.data.parseTime import org.xtimms.psu.schedule.data.parseTime
import org.xtimms.psu.schedule.data.shortWeekday import org.xtimms.psu.schedule.data.shortWeekday
import org.xtimms.psu.schedule.data.today import org.xtimms.psu.schedule.data.today
import org.xtimms.psu.schedule.data.withPlace
import java.time.Duration import java.time.Duration
import java.time.LocalDate import java.time.LocalDate
@@ -15,6 +16,8 @@ import java.time.LocalDate
data class Upcoming(val date: LocalDate, val start: String, val end: String?, val lessons: List<Lesson>, val running: Boolean) { data class Upcoming(val date: LocalDate, val start: String, val end: String?, val lessons: List<Lesson>, val running: Boolean) {
val subject get() = lessons.joinToString(" / ") { it.subject } val subject get() = lessons.joinToString(" / ") { it.subject }
val rooms get() = lessons.flatMap { if (it.distance) listOf("дист.") else it.rooms }.distinct().joinToString(", ") val rooms get() = lessons.flatMap { if (it.distance) listOf("дист.") else it.rooms }.distinct().joinToString(", ")
/** "ауд. 203Г · Полоцк": the rooms with the town the document names. */
val where get() = withPlace(rooms.takeIf { it.isNotEmpty() }?.let { "ауд. $it" }, lessons.firstNotNullOfOrNull { it.place })
/** "Идёт · ещё 40 мин", "Через 20 мин", "Завтра, 10:05" or "Пн, 10:05". */ /** "Идёт · ещё 40 мин", "Через 20 мин", "Завтра, 10:05" or "Пн, 10:05". */
fun whenLabel(): String { fun whenLabel(): String {

View File

@@ -65,6 +65,11 @@ final class AppModel {
t.slots(on: date, subgroup: subgroup, hidden: hidden) t.slots(on: date, subgroup: subgroup, hidden: hidden)
} }
/** "Занятия в Полоцке" over the date, for the student's subgroup. */
func places(_ t: Timetable, on date: CivilDate) -> [DayPlace] {
t.places(on: date, subgroup: subgroup, hidden: hidden)
}
/** The open group's last republished document: what changed for it. */ /** The open group's last republished document: what changed for it. */
var changes: ScheduleChanges? { var changes: ScheduleChanges? {
guard let t = timetable else { return nil } guard let t = timetable else { return nil }

View File

@@ -35,10 +35,11 @@ enum Reminders {
let content = UNMutableNotificationContent() let content = UNMutableNotificationContent()
content.title = slot.lessons.map(\.subject).joined(separator: " / ") content.title = slot.lessons.map(\.subject).joined(separator: " / ")
let first = slot.lessons[0] let first = slot.lessons[0]
let place = Array(Set(slot.lessons.flatMap(\.rooms))).sorted().joined(separator: ", ") let rooms = Array(Set(slot.lessons.flatMap(\.rooms))).sorted().joined(separator: ", ")
let place = withPlace(rooms.isEmpty ? nil : "ауд. " + rooms, slot.lessons.compactMap(\.place).first)
var text = slot.start.description var text = slot.start.description
if let pair = first.pair { text += " · \(pair) пара" } if let pair = first.pair { text += " · \(pair) пара" }
if !place.isEmpty { text += " · ауд. \(place)" } if let place { text += " · \(place)" }
if slot.lessons.contains(where: \.uncertain) { text += " · сверьте недели" } if slot.lessons.contains(where: \.uncertain) { text += " · сверьте недели" }
content.body = text content.body = text
content.sound = .default content.sound = .default

View File

@@ -225,6 +225,7 @@ private struct DayAgenda: View {
} }
if !slots.isEmpty { if !slots.isEmpty {
Text("Ваши занятия").font(.headline) Text("Ваши занятия").font(.headline)
if let t = model.timetable { DayPlaces(places: model.places(t, on: date)) }
ForEach(slots) { slot in ForEach(slots) { slot in
ForEach(slot.lessons, id: \.key) { lesson in ForEach(slot.lessons, id: \.key) { lesson in
Button { details = LessonRef(lesson: lesson, date: date) } label: { Button { details = LessonRef(lesson: lesson, date: date) } label: {

View File

@@ -79,7 +79,7 @@ struct ChangesSheet: View {
Section("Добавлено · \(record.added.count)") { Section("Добавлено · \(record.added.count)") {
ForEach(record.added, id: \.self) { row in ForEach(record.added, id: \.self) { row in
item(row, details: [row.lesson.type, row.lesson.teachers.joined(separator: ", "), item(row, details: [row.lesson.type, row.lesson.teachers.joined(separator: ", "),
row.lesson.rooms.map { "ауд. \($0)" }.joined(separator: ", "), row.lesson.condition]) withPlace(row.lesson.rooms.map { "ауд. \($0)" }.joined(separator: ", "), row.lesson.place) ?? "", row.lesson.condition])
} }
} }
} }

View File

@@ -142,6 +142,55 @@ struct Banner: View {
} }
} }
/// "Занятия в Полоцке" above a day's lessons: Word marks the days a group spends in another
/// town, and every lesson of that day is there.
struct DayPlaces: View {
var places: [DayPlace]
var body: some View {
if !places.isEmpty {
VStack(alignment: .leading, spacing: 10) {
ForEach(places, id: \.self) { p in
HStack(spacing: 14) {
Image(systemName: "building.2")
.font(.title3)
.foregroundStyle(Color.indigo)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 2) {
Text(p.text).font(.headline)
Text(p.subgroup.map { "\($0) · пометка в Word на этот день" } ?? "Пометка в Word на этот день")
.font(.subheadline)
.foregroundStyle(.secondary)
}
Spacer(minLength: 0)
}
.accessibilityElement(children: .combine)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(Color.indigo.opacity(0.14), in: .rect(cornerRadius: 18, style: .continuous))
}
}
}
/// The town under a day's date in the week grid: short, in the note's colour.
struct DayPlaceMark: View {
var places: [DayPlace]
var body: some View {
if let first = places.first {
Text(first.place)
.font(.caption2.weight(.semibold))
.lineLimit(1)
.padding(.horizontal, 4)
.padding(.vertical, 1)
.foregroundStyle(Color.indigo)
.background(Color.indigo.opacity(0.14), in: .rect(cornerRadius: 4, style: .continuous))
}
}
}
/// The status banner of the app model, if it has something to say. /// The status banner of the app model, if it has something to say.
struct StatusBanner: View { struct StatusBanner: View {
@Environment(AppModel.self) private var model @Environment(AppModel.self) private var model

View File

@@ -90,7 +90,7 @@ struct HomeView: View {
WeekReport(timetable: t) WeekReport(timetable: t)
SessionCard(timetable: t) { session = true } SessionCard(timetable: t) { session = true }
DayTitle(label: "Сегодня", date: today) DayTitle(label: "Сегодня", date: today)
TodayCard(slots: todaySlots, onOpen: { details = LessonRef(lesson: $0, date: today) }, onDay: { router.openDay(today) }) TodayCard(slots: todaySlots, places: model.places(t, on: today), onOpen: { details = LessonRef(lesson: $0, date: today) }, onDay: { router.openDay(today) })
AttendanceCard(slots: todaySlots) AttendanceCard(slots: todaySlots)
NavigationLink(value: HomeRoute.subjects) { SubjectsLink(timetable: t) } NavigationLink(value: HomeRoute.subjects) { SubjectsLink(timetable: t) }
.buttonStyle(.plain) .buttonStyle(.plain)
@@ -99,6 +99,7 @@ struct HomeView: View {
let (day, slots) = next let (day, slots) = next
DayTitle(label: day == today.adding(days: 1) ? "Завтра" : day.weekdayName, date: day) DayTitle(label: day == today.adding(days: 1) ? "Завтра" : day.weekdayName, date: day)
SectionCard(symbol: "calendar.badge.checkmark", title: pairs(slots.count)) { SectionCard(symbol: "calendar.badge.checkmark", title: pairs(slots.count)) {
DayPlaces(places: model.places(t, on: day))
ForEach(slots) { slot in ForEach(slots) { slot in
ForEach(slot.lessons, id: \.key) { lesson in ForEach(slot.lessons, id: \.key) { lesson in
Button { details = LessonRef(lesson: lesson, date: day) } label: { CompactLesson(lesson: lesson, slot: slot) } Button { details = LessonRef(lesson: lesson, date: day) } label: { CompactLesson(lesson: lesson, slot: slot) }
@@ -264,6 +265,7 @@ private struct DayChip: View {
private struct TodayCard: View { private struct TodayCard: View {
@Environment(AppModel.self) private var model @Environment(AppModel.self) private var model
var slots: [Slot] var slots: [Slot]
var places: [DayPlace]
var onOpen: (Lesson) -> Void var onOpen: (Lesson) -> Void
var onDay: () -> Void var onDay: () -> Void
@@ -276,6 +278,7 @@ private struct TodayCard: View {
} else if left.isEmpty { } else if left.isEmpty {
Quiet(title: "Пары на сегодня закончились", text: "Сегодня было \(pairs(slots.count)).") Quiet(title: "Пары на сегодня закончились", text: "Сегодня было \(pairs(slots.count)).")
} else { } else {
DayPlaces(places: places)
let first = left[0] let first = left[0]
ForEach(first.lessons, id: \.key) { lesson in ForEach(first.lessons, id: \.key) { lesson in
Button { onOpen(lesson) } label: { FeaturedLesson(lesson: lesson, slot: first, clock: clock) } Button { onOpen(lesson) } label: { FeaturedLesson(lesson: lesson, slot: first, clock: clock) }
@@ -328,7 +331,9 @@ private struct FeaturedLesson: View {
.background(running ? Color.brand : Color.brand.opacity(0.14), in: .capsule) .background(running ? Color.brand : Color.brand.opacity(0.14), in: .capsule)
} }
if lesson.distance { Meta(symbol: "mappin.and.ellipse", text: "Дистанционно") } if lesson.distance { Meta(symbol: "mappin.and.ellipse", text: "Дистанционно") }
else if show.rooms && !lesson.rooms.isEmpty { Meta(symbol: "mappin.and.ellipse", text: "Ауд. " + lesson.rooms.joined(separator: ", ")) } else if let room = withPlace(show.rooms && !lesson.rooms.isEmpty ? "Ауд. " + lesson.rooms.joined(separator: ", ") : nil, lesson.place) {
Meta(symbol: "mappin.and.ellipse", text: room)
}
if show.teachers && !lesson.teachers.isEmpty { Meta(symbol: "person", text: lesson.teachers.joined(separator: ", ")) } if show.teachers && !lesson.teachers.isEmpty { Meta(symbol: "person", text: lesson.teachers.joined(separator: ", ")) }
} }
Spacer(minLength: 0) Spacer(minLength: 0)

View File

@@ -38,8 +38,11 @@ struct LessonCard: View {
} }
FlowLayout(spacing: 14) { FlowLayout(spacing: 14) {
if lesson.distance { Meta(symbol: "mappin.and.ellipse", text: "Дистанционно") } if lesson.distance { Meta(symbol: "mappin.and.ellipse", text: "Дистанционно") }
else if show.rooms && !lesson.rooms.isEmpty { else if let room = withPlace(
Meta(symbol: "mappin.and.ellipse", text: lesson.rooms.joined(separator: ", ") + (lesson.roomMoved ? " (перенос)" : "")) show.rooms && !lesson.rooms.isEmpty ? lesson.rooms.joined(separator: ", ") + (lesson.roomMoved ? " (перенос)" : "") : nil,
lesson.place
) {
Meta(symbol: "mappin.and.ellipse", text: room)
} }
if show.teachers && !lesson.teachers.isEmpty { Meta(symbol: "person", text: lesson.teachers.joined(separator: ", ")) } if show.teachers && !lesson.teachers.isEmpty { Meta(symbol: "person", text: lesson.teachers.joined(separator: ", ")) }
if let sub = lesson.subgroup { Meta(symbol: "person.2", text: sub) } if let sub = lesson.subgroup { Meta(symbol: "person.2", text: sub) }
@@ -123,7 +126,7 @@ struct CompactLesson: View {
if let session = lesson.session { parts.insert(session.label, at: 0) } if let session = lesson.session { parts.insert(session.label, at: 0) }
if let sub = lesson.subgroup, lesson.session != nil { parts.append(sub.lowercased().replacingOccurrences(of: "подгруппа", with: "подгр.")) } if let sub = lesson.subgroup, lesson.session != nil { parts.append(sub.lowercased().replacingOccurrences(of: "подгруппа", with: "подгр.")) }
if lesson.distance { parts.append("дистанционно") } if lesson.distance { parts.append("дистанционно") }
else if show.rooms && !lesson.rooms.isEmpty { parts.append("ауд. " + lesson.rooms.joined(separator: ", ")) } else if let room = withPlace(show.rooms && !lesson.rooms.isEmpty ? "ауд. " + lesson.rooms.joined(separator: ", ") : nil, lesson.place) { parts.append(room) }
if show.tags && !lesson.type.isEmpty && lesson.session == nil { parts.append(lesson.type.lowercased()) } if show.tags && !lesson.type.isEmpty && lesson.session == nil { parts.append(lesson.type.lowercased()) }
return parts.joined(separator: " · ") return parts.joined(separator: " · ")
} }
@@ -163,6 +166,9 @@ struct LessonDetails: View {
value: lesson.distance ? "Дистанционно" : (lesson.rooms.isEmpty ? "Не указана" : lesson.rooms.joined(separator: ", ")), value: lesson.distance ? "Дистанционно" : (lesson.rooms.isEmpty ? "Не указана" : lesson.rooms.joined(separator: ", ")),
extra: lesson.roomMoved ? "В этот день — перенос аудитории" : nil extra: lesson.roomMoved ? "В этот день — перенос аудитории" : nil
) )
if let place = lesson.place {
DetailRow(symbol: "building.2", label: "Город", value: place, extra: "Так указано в Word")
}
DetailRow(symbol: "person.3", label: "Группы", value: lesson.groups.joined(separator: ", "), extra: lesson.subgroup) DetailRow(symbol: "person.3", label: "Группы", value: lesson.groups.joined(separator: ", "), extra: lesson.subgroup)
} }
.card() .card()

View File

@@ -153,6 +153,7 @@ private struct DayPage: View {
EmptyState(title: "Занятий нет", text: "По таблице недель в этот день свободно — можно отдохнуть.", symbol: "sofa") EmptyState(title: "Занятий нет", text: "По таблице недель в этот день свободно — можно отдохнуть.", symbol: "sofa")
.padding(.top, 60) .padding(.top, 60)
} }
DayPlaces(places: model.places(timetable, on: date))
ForEach(slots) { slot in ForEach(slots) { slot in
let running = isToday && clock >= slot.start && clock < slot.end let running = isToday && clock >= slot.start && clock < slot.end
let status: String? = running let status: String? = running
@@ -281,6 +282,7 @@ private struct WordRows: View {
let lessons = day.lessons let lessons = day.lessons
.filter { model.subgroup == nil || $0.subgroup == nil || $0.subgroup == model.subgroup } .filter { model.subgroup == nil || $0.subgroup == nil || $0.subgroup == model.subgroup }
.filter { !model.hidden.contains($0.hideKey) } .filter { !model.hidden.contains($0.hideKey) }
if !lessons.isEmpty { DayPlaces(places: day.places.forSubgroup(model.subgroup)) }
ForEach(lessons, id: \.key) { lesson in ForEach(lessons, id: \.key) { lesson in
Button { onOpen(lesson) } label: { Button { onOpen(lesson) } label: {
LessonCard(lesson: lesson, time: lesson.start + (lesson.end.map { " → \($0)" } ?? ""), date: nil) LessonCard(lesson: lesson, time: lesson.start + (lesson.end.map { " → \($0)" } ?? ""), date: nil)

View File

@@ -165,7 +165,7 @@ private struct DirectoryLessonCard: View {
Text(lesson.start + (lesson.end.map { " → \($0)" } ?? "") + (pair.map { " · \($0) пара" } ?? "")).font(.body.monospacedDigit()) Text(lesson.start + (lesson.end.map { " → \($0)" } ?? "") + (pair.map { " · \($0) пара" } ?? "")).font(.body.monospacedDigit())
FlowLayout(spacing: 14) { FlowLayout(spacing: 14) {
if case .teacher = target { if case .teacher = target {
Meta(symbol: "mappin.and.ellipse", text: lesson.distance ? "Дистанционно" : lesson.rooms.joined(separator: ", ") + (lesson.moved ? " (перенос)" : "")) Meta(symbol: "mappin.and.ellipse", text: lesson.distance ? "Дистанционно" : withPlace(lesson.rooms.joined(separator: ", ") + (lesson.moved ? " (перенос)" : ""), lesson.place) ?? "")
} else if !lesson.teachers.isEmpty { } else if !lesson.teachers.isEmpty {
Meta(symbol: "person", text: lesson.teachers.joined(separator: ", ")) Meta(symbol: "person", text: lesson.teachers.joined(separator: ", "))
} }

View File

@@ -151,7 +151,7 @@ private struct SubjectCard: View {
} }
if let first = summary.upcoming.first { if let first = summary.upcoming.first {
let (slot, lesson) = first let (slot, lesson) = first
Meta(symbol: "clock", text: nextLabel(slot) + (lesson.rooms.isEmpty ? "" : " · ауд. " + lesson.rooms.joined(separator: ", "))) Meta(symbol: "clock", text: nextLabel(slot) + (withPlace(lesson.rooms.isEmpty ? nil : "ауд. " + lesson.rooms.joined(separator: ", "), lesson.place).map { " · " + $0 } ?? ""))
} else { } else {
Meta(symbol: "checkmark.circle", text: "Занятий в этом семестре больше нет") Meta(symbol: "checkmark.circle", text: "Занятий в этом семестре больше нет")
} }
@@ -211,7 +211,7 @@ private struct SubjectDetails: View {
Button { details = LessonRef(lesson: item.1, date: item.0.date) } label: { Button { details = LessonRef(lesson: item.1, date: item.0.date) } label: {
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text(item.0.date.weekdayName + ", " + item.0.date.longLabel).font(.subheadline.weight(.semibold)) Text(item.0.date.weekdayName + ", " + item.0.date.longLabel).font(.subheadline.weight(.semibold))
Text([item.0.timeLabel, item.1.type.isEmpty ? nil : item.1.type.lowercased(), item.1.rooms.isEmpty ? nil : "ауд. " + item.1.rooms.joined(separator: ", ")].compactMap { $0 }.joined(separator: " · ")) Text([item.0.timeLabel, item.1.type.isEmpty ? nil : item.1.type.lowercased(), withPlace(item.1.rooms.isEmpty ? nil : "ауд. " + item.1.rooms.joined(separator: ", "), item.1.place)].compactMap { $0 }.joined(separator: " · "))
.font(.subheadline).foregroundStyle(.secondary) .font(.subheadline).foregroundStyle(.secondary)
} }
} }

View File

@@ -21,11 +21,13 @@ struct WeekGrid: View {
let today = model.today let today = model.today
VStack(spacing: 0) { VStack(spacing: 0) {
HStack(spacing: 0) { // Dates stay on one line when a day has the town under it.
HStack(alignment: .top, spacing: 0) {
Color.clear.frame(width: gutter, height: 1) Color.clear.frame(width: gutter, height: 1)
ForEach(days, id: \.0) { day in ForEach(days, id: \.0) { day in
let d = day.0 let d = day.0
let isToday = d == today let isToday = d == today
let places = model.places(timetable, on: d)
VStack(spacing: 2) { VStack(spacing: 2) {
Text(d.shortWeekday).font(.caption.weight(.medium)).foregroundStyle(isToday ? Color.brand : .secondary) Text(d.shortWeekday).font(.caption.weight(.medium)).foregroundStyle(isToday ? Color.brand : .secondary)
Text("\(d.day)") Text("\(d.day)")
@@ -33,10 +35,12 @@ struct WeekGrid: View {
.foregroundStyle(isToday ? Color.onBrand : .primary) .foregroundStyle(isToday ? Color.onBrand : .primary)
.frame(width: 34, height: 34) .frame(width: 34, height: 34)
.background(isToday ? Color.brand : .clear, in: .circle) .background(isToday ? Color.brand : .clear, in: .circle)
// "Занятия в Полоцке": the town of the whole day, under its date.
DayPlaceMark(places: places)
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.accessibilityElement(children: .ignore) .accessibilityElement(children: .ignore)
.accessibilityLabel(d.weekdayName + ", " + d.longLabel) .accessibilityLabel(d.weekdayName + ", " + d.longLabel + places.map { ", " + $0.text }.joined())
} }
} }
.padding(.bottom, 8) .padding(.bottom, 8)
@@ -117,7 +121,8 @@ private struct LessonBlock: View {
Text(wide ? lesson.subject : abbreviate(lesson.subject)) Text(wide ? lesson.subject : abbreviate(lesson.subject))
.font(.caption.weight(.semibold)) .font(.caption.weight(.semibold))
.lineLimit(wide ? 3 : 2) .lineLimit(wide ? 3 : 2)
if show.rooms, let room = lesson.distance ? "дист." : lesson.rooms.first { // A narrow column keeps the room whole: the day's town is under its date.
if let room = lesson.distance ? (show.rooms ? "дист." : nil) : (wide ? withPlace(show.rooms ? lesson.rooms.first : nil, lesson.place) : (show.rooms ? lesson.rooms.first : nil) ?? lesson.place) {
Text(room).font(.caption2).lineLimit(1).opacity(0.9) Text(room).font(.caption2).lineLimit(1).opacity(0.9)
} }
Spacer(minLength: 0) Spacer(minLength: 0)
@@ -140,6 +145,6 @@ private struct LessonBlock: View {
} }
} }
.accessibilityElement(children: .ignore) .accessibilityElement(children: .ignore)
.accessibilityLabel((lesson.session.map { "\($0.label): " } ?? "") + "\(lesson.subject), \(lesson.start)" + (lesson.rooms.isEmpty ? "" : ", ауд. " + lesson.rooms.joined(separator: ", "))) .accessibilityLabel((lesson.session.map { "\($0.label): " } ?? "") + "\(lesson.subject), \(lesson.start)" + (withPlace(lesson.rooms.isEmpty ? nil : "ауд. " + lesson.rooms.joined(separator: ", "), lesson.place).map { ", " + $0 } ?? ""))
} }
} }

View File

@@ -106,6 +106,7 @@ private func fields(_ a: ChangedLesson, _ b: ChangedLesson) -> [FieldChange] {
("Вид занятия", a.lesson.type, b.lesson.type), ("Вид занятия", a.lesson.type, b.lesson.type),
("Преподаватель", a.lesson.teachers.joined(separator: ", "), b.lesson.teachers.joined(separator: ", ")), ("Преподаватель", a.lesson.teachers.joined(separator: ", "), b.lesson.teachers.joined(separator: ", ")),
("Аудитория", a.lesson.rooms.joined(separator: ", "), b.lesson.rooms.joined(separator: ", ")), ("Аудитория", a.lesson.rooms.joined(separator: ", "), b.lesson.rooms.joined(separator: ", ")),
("Город", a.lesson.place ?? "", b.lesson.place ?? ""),
] ]
let shown = { (s: String) in s.trimmed.isEmpty ? "не указано" : s } let shown = { (s: String) in s.trimmed.isEmpty ? "не указано" : s }
return pairs.filter { $0.1.trimmed != $0.2.trimmed }.map { FieldChange(label: $0.0, from: shown($0.1), to: shown($0.2)) } return pairs.filter { $0.1.trimmed != $0.2.trimmed }.map { FieldChange(label: $0.0, from: shown($0.1), to: shown($0.2)) }

View File

@@ -51,11 +51,39 @@ struct Day: Codable, Hashable {
var weekday: Int var weekday: Int
var week: Week? var week: Week?
var lessons: [Lesson] = [] var lessons: [Lesson] = []
var places: [DayPlace] = []
} }
struct WeeklyDay: Codable, Hashable { struct WeeklyDay: Codable, Hashable {
var day: Int var day: Int
var lessons: [Lesson] = [] var lessons: [Lesson] = []
var places: [DayPlace] = []
}
/**
* "Занятия в Полоцке" over a day in Word: that day the group's lessons (or one subgroup's,
* when `subgroup` is set) are in another town (server/timetable.js, shared/places.js).
*/
struct DayPlace: Codable, Hashable {
var place: String
var text: String
var subgroup: String?
}
extension Array where Element == DayPlace {
/** The notes for the student's subgroup; a note for their own subgroup reads as the whole day's. */
func forSubgroup(_ subgroup: String?) -> [DayPlace] {
var seen = Set<DayPlace>()
return filter { subgroup == nil || $0.subgroup == nil || $0.subgroup == subgroup }
.map { subgroup == nil ? $0 : DayPlace(place: $0.place, text: $0.text, subgroup: nil) }
.filter { seen.insert($0).inserted }
}
}
/** A room label with the lesson's town when the document names one: "ауд. 203Г · Полоцк", or the town alone. */
func withPlace(_ room: String?, _ place: String?) -> String? {
let parts = [room, place].compactMap { $0 }.filter { !$0.isEmpty }
return parts.isEmpty ? nil : parts.joined(separator: " · ")
} }
struct Lesson: Codable, Hashable { struct Lesson: Codable, Hashable {
@@ -68,6 +96,8 @@ struct Lesson: Codable, Hashable {
var teachers: [String] = [] var teachers: [String] = []
var rooms: [String] = [] var rooms: [String] = []
var roomMoved: Bool = false var roomMoved: Bool = false
/** The town, when the cell or the day's note names one: "Полоцк". */
var place: String?
var distance: Bool = false var distance: Bool = false
var condition: String = "" var condition: String = ""
var variant: String = "all" var variant: String = "all"
@@ -139,6 +169,7 @@ struct DirectoryLesson: Codable, Hashable {
var teachers: [String] = [] var teachers: [String] = []
var rooms: [String] = [] var rooms: [String] = []
var moved: Bool = false var moved: Bool = false
var place: String?
var distance: Bool = false var distance: Bool = false
var groups: [String] = [] var groups: [String] = []
var condition: String = "" var condition: String = ""
@@ -266,6 +297,7 @@ extension Day {
weekday = try c.decodeIfPresent(Int.self, forKey: .weekday) ?? 0 weekday = try c.decodeIfPresent(Int.self, forKey: .weekday) ?? 0
week = try c.decodeIfPresent(Week.self, forKey: .week) week = try c.decodeIfPresent(Week.self, forKey: .week)
lessons = try c.decodeIfPresent([Lesson].self, forKey: .lessons) ?? [] lessons = try c.decodeIfPresent([Lesson].self, forKey: .lessons) ?? []
places = try c.decodeIfPresent([DayPlace].self, forKey: .places) ?? []
} }
} }
@@ -274,6 +306,16 @@ extension WeeklyDay {
let c = try decoder.container(keyedBy: CodingKeys.self) let c = try decoder.container(keyedBy: CodingKeys.self)
day = try c.decode(Int.self, forKey: .day) day = try c.decode(Int.self, forKey: .day)
lessons = try c.decodeIfPresent([Lesson].self, forKey: .lessons) ?? [] lessons = try c.decodeIfPresent([Lesson].self, forKey: .lessons) ?? []
places = try c.decodeIfPresent([DayPlace].self, forKey: .places) ?? []
}
}
extension DayPlace {
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
place = try c.decodeIfPresent(String.self, forKey: .place) ?? ""
text = try c.decodeIfPresent(String.self, forKey: .text) ?? ""
subgroup = try c.decodeIfPresent(String.self, forKey: .subgroup)
} }
} }
@@ -289,6 +331,7 @@ extension Lesson {
teachers = try c.decodeIfPresent([String].self, forKey: .teachers) ?? [] teachers = try c.decodeIfPresent([String].self, forKey: .teachers) ?? []
rooms = try c.decodeIfPresent([String].self, forKey: .rooms) ?? [] rooms = try c.decodeIfPresent([String].self, forKey: .rooms) ?? []
roomMoved = try c.decodeIfPresent(Bool.self, forKey: .roomMoved) ?? false roomMoved = try c.decodeIfPresent(Bool.self, forKey: .roomMoved) ?? false
place = try c.decodeIfPresent(String.self, forKey: .place)
distance = try c.decodeIfPresent(Bool.self, forKey: .distance) ?? false distance = try c.decodeIfPresent(Bool.self, forKey: .distance) ?? false
condition = try c.decodeIfPresent(String.self, forKey: .condition) ?? "" condition = try c.decodeIfPresent(String.self, forKey: .condition) ?? ""
variant = try c.decodeIfPresent(String.self, forKey: .variant) ?? "all" variant = try c.decodeIfPresent(String.self, forKey: .variant) ?? "all"
@@ -375,6 +418,7 @@ extension DirectoryLesson {
teachers = try c.decodeIfPresent([String].self, forKey: .teachers) ?? [] teachers = try c.decodeIfPresent([String].self, forKey: .teachers) ?? []
rooms = try c.decodeIfPresent([String].self, forKey: .rooms) ?? [] rooms = try c.decodeIfPresent([String].self, forKey: .rooms) ?? []
moved = try c.decodeIfPresent(Bool.self, forKey: .moved) ?? false moved = try c.decodeIfPresent(Bool.self, forKey: .moved) ?? false
place = try c.decodeIfPresent(String.self, forKey: .place)
distance = try c.decodeIfPresent(Bool.self, forKey: .distance) ?? false distance = try c.decodeIfPresent(Bool.self, forKey: .distance) ?? false
groups = try c.decodeIfPresent([String].self, forKey: .groups) ?? [] groups = try c.decodeIfPresent([String].self, forKey: .groups) ?? []
condition = try c.decodeIfPresent(String.self, forKey: .condition) ?? "" condition = try c.decodeIfPresent(String.self, forKey: .condition) ?? ""

View File

@@ -34,6 +34,13 @@ extension Timetable {
.filter { !hidden.contains($0.hideKey) } .filter { !hidden.contains($0.hideKey) }
} }
/// "Занятия в Полоцке" on the date for the student's subgroup, only on a day they have lessons.
func places(on date: CivilDate, subgroup: String?, hidden: Set<String>) -> [DayPlace] {
guard let day = day(date), !day.places.isEmpty,
!lessons(on: date, subgroup: subgroup, hidden: hidden).isEmpty else { return [] }
return day.places.forSubgroup(subgroup)
}
func slots(on date: CivilDate, subgroup: String?, hidden: Set<String>) -> [Slot] { func slots(on date: CivilDate, subgroup: String?, hidden: Set<String>) -> [Slot] {
let lessons = lessons(on: date, subgroup: subgroup, hidden: hidden) let lessons = lessons(on: date, subgroup: subgroup, hidden: hidden)
var order: [String] = [] var order: [String] = []

View File

@@ -70,7 +70,7 @@ private func status(_ slot: Slot, day: CivilDate, at date: Date) -> String? {
private func place(_ lesson: Lesson) -> String? { private func place(_ lesson: Lesson) -> String? {
if lesson.distance { return "дист." } if lesson.distance { return "дист." }
return lesson.rooms.isEmpty ? nil : "ауд. " + lesson.rooms.joined(separator: ", ") return withPlace(lesson.rooms.isEmpty ? nil : "ауд. " + lesson.rooms.joined(separator: ", "), lesson.place)
} }
/// "Сегодня": the day's lessons ahead, with the current one marked. /// "Сегодня": the day's lessons ahead, with the current one marked.

View File

@@ -12,16 +12,26 @@ export const lessonLine = (l) =>
(l.variant === "upper" ? ", верхняя строка" : l.variant === "lower" ? ", нижняя строка" : ""), (l.variant === "upper" ? ", верхняя строка" : l.variant === "lower" ? ", нижняя строка" : ""),
l.subject, l.subject,
l.type, l.type,
l.room, // The town only when the cell names one or the day's note puts the lesson there.
[l.room, l.place].filter(Boolean).join(", "),
l.teacher, l.teacher,
[...l.groups, ...l.subgroups.map((s) => `${s.group}/${s.label.replace("Подгруппа ", "п")}`)].join(","), [...l.groups, ...l.subgroups.map((s) => `${s.group}/${s.label.replace("Подгруппа ", "п")}`)].join(","),
] ]
.map((part) => String(part ?? "").replace(/\s+/g, " ").trim()) .map((part) => String(part ?? "").replace(/\s+/g, " ").trim())
.join(" | "); .join(" | ");
// "Занятия в Полоцке": a day of a group or subgroup in another town, not a lesson.
const placeLine = (p) =>
[
`@ ${p.date || DAYS[p.day]}`,
p.text,
[...p.groups, ...p.subgroups.map((s) => `${s.group}/${s.label.replace("Подгруппа ", "п")}`)].join(","),
].join(" | ");
/** Sorted lesson lines of a parsed document, with its groups and period on top. */ /** Sorted lesson lines of a parsed document, with its groups and period on top. */
export const snapshotOf = (schedule) => [ export const snapshotOf = (schedule) => [
`# ${schedule.period} · ${schedule.groups.join(", ")}`, `# ${schedule.period} · ${schedule.groups.join(", ")}`,
...(schedule.places || []).map(placeLine).sort(),
...schedule.lessons.map(lessonLine).sort(), ...schedule.lessons.map(lessonLine).sort(),
]; ];

View File

@@ -311,6 +311,7 @@ export function dayOf(idx, calendar, { teacher, room }, date) {
teachers, teachers,
rooms, rooms,
moved: movedOn(entry, date), moved: movedOn(entry, date),
place: l.place || null,
distance: entry.distance, distance: entry.distance,
groups: [], groups: [],
documents: [], documents: [],

View File

@@ -78,8 +78,9 @@ app.use("/api", (_req, res, next) => {
// 10 — parserVersion in /api/catalog; // 10 — parserVersion in /api/catalog;
// 11 — the catalog is checked by ETag every 5 minutes; `watch`, `telegram` and `catalog.lastCrawl` in /api/health; // 11 — the catalog is checked by ETag every 5 minutes; `watch`, `telegram` and `catalog.lastCrawl` in /api/health;
// 12 — a JSON log line per request; // 12 — a JSON log line per request;
// 13 — the dashboard at /status (password), /api/health only says alive, ready and versions. // 13 — the dashboard at /status (password), /api/health only says alive, ready and versions;
const API_VERSION = 13; // 14 — a lesson's `place` and a day's `places` ("Занятия в Полоцке") in /api/timetable.
const API_VERSION = 14;
/** /**
* What the server still waits for before it answers from memory: the catalog, the week table * What the server still waits for before it answers from memory: the catalog, the week table

View File

@@ -8,8 +8,9 @@ import { join } from "node:path";
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { parseWeekRanges, parseTextDates, DATE_LIST, weekday } from "../shared/weeks.js"; import { parseWeekRanges, parseTextDates, DATE_LIST, weekday } from "../shared/weeks.js";
import { roomMoves, onlyPlace, weekPlan, partsByWeek } from "./rooms.js"; import { roomMoves, onlyPlace, weekPlan, partsByWeek } from "./rooms.js";
import { placeNote, lessonPlace, placeOf } from "./places.js";
const run = promisify(execFile); const run = promisify(execFile);
export const PARSER_VERSION = 13; export const PARSER_VERSION = 14;
const days = [ const days = [
"понедельник", "понедельник",
"вторник", "вторник",
@@ -66,7 +67,9 @@ function conditionIn(value) {
); );
return range?.[0].trim() || ""; return range?.[0].trim() || "";
} }
export function details(raw) { export function details(text) {
// "Новополоцк" on a line of its own, "246(Новополоцк)": the town is no part of the subject or room.
const { place, rest: raw } = lessonPlace(text);
const condition = conditionIn(raw); const condition = conditionIn(raw);
let rest = raw.slice(condition.length).trim(); let rest = raw.slice(condition.length).trim();
// "Экзамен. Финансы …" or "Зачёт: …": the label is the kind of session event, not the subject. // "Экзамен. Финансы …" or "Зачёт: …": the label is the kind of session event, not the subject.
@@ -106,7 +109,7 @@ export function details(raw) {
// A long note under a capitalised heading ("ВВЕДЕНИЕ В ВЫСШЕЕ ОБРАЗОВАНИЕ / г. Полоцк, …"): // A long note under a capitalised heading ("ВВЕДЕНИЕ В ВЫСШЕЕ ОБРАЗОВАНИЕ / г. Полоцк, …"):
// the heading names it; the whole text stays in the details. // the heading names it; the whole text stays in the details.
const heading = rest.split("\n")[0].trim(); const heading = rest.split("\n")[0].trim();
if (subject === rest && rest.split("\n").length >= 3 && /[А-ЯЁ]{4}/u.test(heading) && !/[а-яё]/u.test(heading)) subject = heading; if (subject === rest && text.split("\n").length >= 3 && /[А-ЯЁ]{4}/u.test(heading) && !/[а-яё]/u.test(heading)) subject = heading;
const defence = /^защита/i.test(leading?.[1] || ""); const defence = /^защита/i.test(leading?.[1] || "");
const token = const token =
kind?.[1].replace(/[.\s/]/g, "").toLowerCase() || kind?.[1].replace(/[.\s/]/g, "").toLowerCase() ||
@@ -148,6 +151,7 @@ export function details(raw) {
: "Занятие", : "Занятие",
condition, condition,
...(own && { ownSubgroup: Number(own[1]) }), ...(own && { ownSubgroup: Number(own[1]) }),
...(place && { place }),
}; };
} }
// The kind of a lesson in brackets: "(л.)", "(п.з.)", "(уст.л.)", "(конс.)". // The kind of a lesson in brackets: "(л.)", "(п.з.)", "(уст.л.)", "(конс.)".
@@ -394,6 +398,8 @@ const isoDate = (m) => `${m[3]}-${m[2].padStart(2, "0")}-${m[1].padStart(2, "0")
export function parseDocx(buffer, source = {}) { export function parseDocx(buffer, source = {}) {
const document = readDocx(buffer); const document = readDocx(buffer);
const lessons = [], const lessons = [],
// "Занятия в Полоцке": the days a group, or one of its subgroups, spends in another town.
places = [],
groups = new Set(), groups = new Set(),
warnings = new Set(); warnings = new Set();
// The catalog folder ("2026–2027") wins: some files keep last year's header text. // The catalog folder ("2026–2027") wins: some files keep last year's header text.
@@ -548,6 +554,28 @@ export function parseDocx(buffer, source = {}) {
const spansBoth = const spansBoth =
cell.merge === "restart" && cell.merge === "restart" &&
rows[i + 1]?.find((c) => c.col === cell.col)?.merge === "continue"; rows[i + 1]?.find((c) => c.col === cell.col)?.merge === "continue";
const columnSubgroups = sessionColumns
? []
: ranges
.filter(
(r) =>
targetGroups.includes(r.group) &&
cell.col >= r.col &&
cell.col + cell.span <= r.end &&
cell.span < r.end - r.col,
)
.map((r) => ({
group: r.group,
label: `Подгруппа ${cell.col - r.col + 1}`,
}));
// "Занятия в Полоцке" over the day's first row is no lesson: the column's lessons that day
// are in that town. A lesson may follow it in the same cell.
const note = placeNote(raw);
if (note) {
places.push({ day, date, groups: targetGroups, subgroups: columnSubgroups, place: note.place, text: note.text });
raw = note.rest;
if (!raw.trim()) return;
}
for (const { text: whole, main: alternative, plan } of entries(raw)) { for (const { text: whole, main: alternative, plan } of entries(raw)) {
// "Единый день информирования 10.09;15.10;19.11;17.12": the dates are not part of the // "Единый день информирования 10.09;15.10;19.11;17.12": the dates are not part of the
// subject, nor is a move to another room on a date ("412н 26.10.2026-333"). // subject, nor is a move to another room on a date ("412н 26.10.2026-333").
@@ -571,20 +599,7 @@ export function parseDocx(buffer, source = {}) {
data = read(alternative.slice(lead.length).replace(/^[\s–—:-]+/, "")); data = read(alternative.slice(lead.length).replace(/^[\s–—:-]+/, ""));
if (oneDate) data.condition = lead.text; if (oneDate) data.condition = lead.text;
} }
const subgroups = sessionColumns const subgroups = [...columnSubgroups];
? []
: ranges
.filter(
(r) =>
targetGroups.includes(r.group) &&
cell.col >= r.col &&
cell.col + cell.span <= r.end &&
cell.span < r.end - r.col,
)
.map((r) => ({
group: r.group,
label: `Подгруппа ${cell.col - r.col + 1}`,
}));
// A subgroup named in the cell ("2п/гр.") when the column does not tell one. // A subgroup named in the cell ("2п/гр.") when the column does not tell one.
const own = data.ownSubgroup; const own = data.ownSubgroup;
delete data.ownSubgroup; delete data.ownSubgroup;
@@ -677,12 +692,22 @@ export function parseDocx(buffer, source = {}) {
warnings.add( warnings.add(
"Номера подгрупп обозначают столбцы внутри группы в исходном Word.", "Номера подгрупп обозначают столбцы внутри группы в исходном Word.",
); );
// A lesson on a day the group spends in another town is there too, unless its cell says otherwise.
const labels = new Map();
for (const l of lessons)
for (const s of l.subgroups) labels.set(s.group, new Set([...(labels.get(s.group) || []), s.label]));
for (const lesson of lessons) {
if (lesson.place || !places.length) continue;
const place = placeOf(lesson, places, (group) => [...(labels.get(group) || [])]);
if (place) lesson.place = place;
}
return { return {
id: createHash("sha256").update(buffer).update(source.fileId || source.name || "").digest("hex").slice(0, 20), id: createHash("sha256").update(buffer).update(source.fileId || source.name || "").digest("hex").slice(0, 20),
groups: [...groups], groups: [...groups],
lessons: lessons.sort( lessons: lessons.sort(
(a, b) => a.day - b.day || a.start.localeCompare(b.start), (a, b) => a.day - b.day || a.start.localeCompare(b.start),
), ),
...(places.length && { places }),
notes: document.notes, notes: document.notes,
warnings: [...warnings], warnings: [...warnings],
source: { source: {

83
server/places.js Normal file
View File

@@ -0,0 +1,83 @@
// Towns of a lesson, as Word cells name them. Most lessons are in Novopolotsk; some faculties
// teach in Polotsk, and a cell says when a lesson or a whole day is in the other town:
// "Занятия в Полоцке" over a day's first row (that day, the column's lessons are in Polotsk),
// "Новополоцк" or "г.Новополоцк" on a line of its own, "246(Новополоцк)" after a room.
// Towns a cell may name without "г.": a bare capitalised word is otherwise a subject.
const TOWNS = "Новополоцк|Полоцк|Междуречье|Витебск|Минск";
const TOWN = String.raw`(?:г\.\s*([А-ЯЁ][а-яё]+(?:-[А-ЯЁ][а-яё]+)?)|(${TOWNS})е?)`;
// "Занятия в Полоцке", "Занятия проходят в г. Новополоцк": the rest of the line, if any, is a lesson.
const NOTE = new RegExp(
String.raw`^[Зз]анятия\s+(?:(?:проход[яи]т|будут\s+проходить)\s+)?в\s+(?:г\.\s*)?([А-ЯЁ][а-яё]+(?:-[А-ЯЁ][а-яё]+)?)(?=$|[\s.,;:!–—-])[.!]?`,
"u",
);
// A line that starts with the town: "г.Новополоцк", or "г. Полоцк, Дом офицеров" with the venue.
const LINE = new RegExp(String.raw`^\(?${TOWN}\)?(?:[.,]?$|(,\s*\S.*))`, "u");
// "246(Новополоцк)", "121 (г.Новополоцк)".
const AFTER_ROOM = new RegExp(String.raw`(?<=\d[А-ЯЁа-яё]{0,2})\s*\(${TOWN}\)`, "u");
/** "Полоцке" → "Полоцк", "Витебске" → "Витебск"; "Междуречье" stays. */
export const townName = (word) => word.replace(/(?<=(?:к|ст))е$/u, "");
/**
* A note that a day is in another town, on a line of the cell: `{text, place, rest}`, the
* cell without the note in `rest`; null when the cell has none.
*/
export function placeNote(text) {
const lines = (text || "").split("\n");
for (const [i, line] of lines.entries()) {
const m = line.trim().match(NOTE);
if (!m) continue;
const rest = [...lines.slice(0, i), line.trim().slice(m[0].length).trim(), ...lines.slice(i + 1)]
.filter((l) => l.trim())
.join("\n");
return { text: m[0].replace(/[.!]$/, "").replace(/\s+/g, " "), place: townName(m[1]), rest };
}
return null;
}
/** The town a lesson's own text names, and the text without it: `{place, rest}`. */
export function lessonPlace(text) {
let place = null;
const rest = (text || "")
.split("\n")
.filter((line) => {
const m = line.trim().match(LINE);
if (m && !place) place = townName(m[1] || m[2]);
// The town alone goes; a venue after it stays in the text.
return !m || m[3];
})
.join("\n")
.replace(AFTER_ROOM, (_, named, town) => {
place ||= townName(named || town);
return "";
});
return { place, rest };
}
/**
* The town of a lesson from the notes of its day: for every group of the lesson, a note for its
* subgroup or the whole group, or — for a lesson of the whole group — notes in every subgroup
* that name the same town. `labelsOf(group)`: the group's subgroup labels.
*/
export function placeOf(lesson, places, labelsOf) {
let found = null;
for (const group of lesson.groups) {
const notes = places.filter(
(p) => p.day === lesson.day && (!p.date || p.date === lesson.date) && p.groups.includes(group),
);
if (!notes.length) return null;
const label = lesson.subgroups.find((s) => s.group === group)?.label;
const own = (p) => p.subgroups.filter((s) => s.group === group).map((s) => s.label);
let place = notes.find((p) => !own(p).length || (label && own(p).includes(label)))?.place;
if (!place && !label) {
const towns = new Map(notes.flatMap((p) => own(p).map((l) => [l, p.place])));
const labels = labelsOf(group);
if (labels.length && labels.every((l) => towns.has(l)) && new Set(towns.values()).size === 1)
place = towns.get(labels[0]);
}
if (!place || (found && found !== place)) return null;
found = place;
}
return found;
}

View File

@@ -2,6 +2,7 @@
// third-party clients use it instead of re-implementing the white/green week rules. // third-party clients use it instead of re-implementing the white/green week rules.
import { semesterView, addDays, weekday, weekIndexOf } from "../shared/weeks.js"; import { semesterView, addDays, weekday, weekIndexOf } from "../shared/weeks.js";
import { lessonKey, subgroupsOf } from "../shared/lessons.js"; import { lessonKey, subgroupsOf } from "../shared/lessons.js";
import { placesOn } from "../shared/places.js";
import { occurrence, peopleAndRooms, roomsOn, teachersOn, movedOn } from "./directory.js"; import { occurrence, peopleAndRooms, roomsOn, teachersOn, movedOn } from "./directory.js";
import { apiError } from "./errors.js"; import { apiError } from "./errors.js";
@@ -24,6 +25,8 @@ function lessonOut(lesson, group, people, date, status, calendar, week = null) {
teachers: date ? teachersOn(people, week) : people.teachers, teachers: date ? teachersOn(people, week) : people.teachers,
rooms, rooms,
roomMoved: Boolean(date && movedOn(people, date)), roomMoved: Boolean(date && movedOn(people, date)),
// "Полоцк": the town, when the cell or the day's note names one.
place: lesson.place || null,
distance: people.distance, distance: people.distance,
condition: lesson.condition, condition: lesson.condition,
variant: lesson.variant, variant: lesson.variant,
@@ -58,6 +61,7 @@ export function timetable({ file, schedule, calendar, group, from, days, range,
} }
let count = Math.min(Math.max(Number(days) || 7, 1), MAX_DAYS); let count = Math.min(Math.max(Number(days) || 7, 1), MAX_DAYS);
const lessons = schedule.lessons.filter((l) => l.groups.includes(group)); const lessons = schedule.lessons.filter((l) => l.groups.includes(group));
const labels = subgroupsOf(schedule, group);
// A session schedule printed with a date on every day needs no week table: its own dates // A session schedule printed with a date on every day needs no week table: its own dates
// say when each lesson takes place, even in a year the table no longer covers. // say when each lesson takes place, even in a year the table no longer covers.
const sessionDates = lessons.every((l) => l.date || l.dates?.length) const sessionDates = lessons.every((l) => l.date || l.dates?.length)
@@ -112,11 +116,14 @@ export function timetable({ file, schedule, calendar, group, from, days, range,
if (entry.lesson.date === date || entry.lesson.dates?.includes(date)) if (entry.lesson.date === date || entry.lesson.dates?.includes(date))
dayLessons.push(lessonOut(entry.lesson, group, entry, date, "yes", calendar)); dayLessons.push(lessonOut(entry.lesson, group, entry, date, "yes", calendar));
dayLessons.sort((a, b) => a.start.localeCompare(b.start)); dayLessons.sort((a, b) => a.start.localeCompare(b.start));
// A day without lessons (a holiday, weeks of practice) is in no town.
const places = dayLessons.length ? placesOn(schedule.places, group, labels, weekday(date), date) : [];
out.push({ out.push({
date, date,
weekday: weekday(date), weekday: weekday(date),
week: week ? { n: week.n, color: week.color } : null, week: week ? { n: week.n, color: week.color } : null,
lessons: dayLessons, lessons: dayLessons,
...(places.length && { places }),
}); });
} }
const weekly = [1, 2, 3, 4, 5, 6, 7] const weekly = [1, 2, 3, 4, 5, 6, 7]
@@ -127,7 +134,11 @@ export function timetable({ file, schedule, calendar, group, from, days, range,
.map((e) => lessonOut(e.lesson, group, e, null, "yes", calendar)) .map((e) => lessonOut(e.lesson, group, e, null, "yes", calendar))
.sort((a, b) => a.start.localeCompare(b.start)), .sort((a, b) => a.start.localeCompare(b.start)),
})) }))
.filter((d) => d.lessons.length); .filter((d) => d.lessons.length)
.map((d) => {
const places = placesOn(schedule.places, group, labels, d.day);
return places.length ? { ...d, places } : d;
});
return { return {
group, group,
document: { document: {

1
shared/lessons.d.ts vendored
View File

@@ -6,6 +6,7 @@ interface LessonLike {
subject: string; subject: string;
teacher: string; teacher: string;
room: string; room: string;
place?: string;
type: string; type: string;
condition: string; condition: string;
variant: "all" | "upper" | "lower"; variant: "all" | "upper" | "lower";

View File

@@ -27,6 +27,8 @@ const FIELDS = [
["day", "День"], ["day", "День"],
["date", "Дата"], ["date", "Дата"],
["room", "Аудитория"], ["room", "Аудитория"],
// "Полоцк": the town a cell or the day's note names (parser 14).
["place", "Город"],
["teacher", "Преподаватель"], ["teacher", "Преподаватель"],
["condition", "Недели"], ["condition", "Недели"],
["type", "Вид занятия"], ["type", "Вид занятия"],

23
shared/places.d.ts vendored Normal file
View File

@@ -0,0 +1,23 @@
/** "Занятия в Полоцке" as the parser keeps it: a day of a group, or of some of its subgroups. */
export interface PlaceNote {
day: number;
date: string | null;
groups: string[];
subgroups: { group: string; label: string }[];
place: string;
text: string;
}
/** A note of one day for one group; `subgroup` null for the whole group. */
export interface DayPlace {
place: string;
text: string;
subgroup: string | null;
}
export function placesOn(
places: PlaceNote[] | null | undefined,
group: string,
labels: string[],
day: number,
date?: string | null,
): DayPlace[];
export function placesFor(list: DayPlace[], subgroup: string | null | undefined): DayPlace[];

34
shared/places.js Normal file
View File

@@ -0,0 +1,34 @@
// "Занятия в Полоцке": Word marks the days a group spends in another town over the day's first
// row; the parser keeps them as notes of the day (server/places.js). The notes of a day for one
// group, shared by the server's timetable and the browser.
/**
* The notes on a weekday (or a date) of the group: `{place, text, subgroup}`, the subgroup null
* for the whole group. The same note in every subgroup (`labels`) is the whole group's.
*/
export function placesOn(places, group, labels, day, date = null) {
const on = (places || [])
.filter((p) => p.groups.includes(group) && p.day === day && (!p.date || p.date === date))
.map((p) => ({ place: p.place, text: p.text, subgroup: p.subgroups.find((s) => s.group === group)?.label || null }));
const whole = on.filter((p) => !p.subgroup);
for (const p of on)
if (
p.subgroup &&
!whole.some((w) => w.text === p.text) &&
labels.length &&
labels.every((label) => on.some((o) => o.subgroup === label && o.text === p.text))
)
whole.push({ ...p, subgroup: null });
const parts = on.filter((p) => p.subgroup && !whole.some((w) => w.text === p.text));
return [...whole, ...parts].filter(
(p, i, all) => all.findIndex((o) => o.text === p.text && o.subgroup === p.subgroup) === i,
);
}
/** The notes for the student's subgroup; a note for their own subgroup reads as the whole day's. */
export function placesFor(list, subgroup) {
return list
.filter((p) => !subgroup || !p.subgroup || p.subgroup === subgroup)
.map((p) => (subgroup ? { ...p, subgroup: null } : p))
.filter((p, i, all) => all.findIndex((o) => o.text === p.text && o.subgroup === p.subgroup) === i);
}

View File

@@ -60,6 +60,7 @@ import {
personalFilter, personalFilter,
subgroupsOf, subgroupsOf,
} from "../shared/lessons"; } from "../shared/lessons";
import { placesFor, placesOn, type DayPlace } from "../shared/places";
import qrcode from "qrcode-generator"; import qrcode from "qrcode-generator";
const SOURCE = const SOURCE =
"https://nextcloud.psu.by/index.php/s/no5zKbMrns6j5jQ?dir=/2026-2027"; "https://nextcloud.psu.by/index.php/s/no5zKbMrns6j5jQ?dir=/2026-2027";
@@ -197,10 +198,10 @@ function LessonCard({
{lesson.teacher} {lesson.teacher}
</span> </span>
)} )}
{lesson.room && ( {(lesson.room || lesson.place) && (
<span> <span>
<Icon name="location_on" size={20} /> <Icon name="location_on" size={20} />
Ауд. {lesson.room} {whereLabel(lesson, "Ауд.")}
</span> </span>
)} )}
{subgroup && ( {subgroup && (
@@ -248,6 +249,29 @@ function LessonCard({
</button> </button>
); );
} }
/** "Ауд. 249Ж · Полоцк": the room with the town the document names, or the town alone. */
const whereLabel = (l: Lesson, prefix: string) =>
[l.room && `${prefix} ${l.room}`, l.place].filter(Boolean).join(" · ");
/**
* "Занятия в Полоцке" over the day's lessons: Word marks the days a group spends in another
* town, and every lesson of that day is there.
*/
function DayPlaces({ places }: { places: DayPlace[] }) {
if (!places.length) return null;
return (
<div className="day-places" role="note">
{places.map((p) => (
<p key={`${p.text}|${p.subgroup}`}>
<Icon name="location_city" />
<span>
<strong>{p.text}</strong>
<small>{p.subgroup ? `${p.subgroup} · пометка в Word на этот день` : "Пометка в Word на этот день"}</small>
</span>
</p>
))}
</div>
);
}
function ScheduleList({ function ScheduleList({
lessons, lessons,
ctx, ctx,
@@ -434,7 +458,7 @@ function ChangesList({ record }: { record: ChangeRecord }) {
<small>{whenLabel(l)}</small> <small>{whenLabel(l)}</small>
<b>{l.subject}</b> <b>{l.subject}</b>
<span className="change-field"> <span className="change-field">
{[l.type, l.teacher, l.room && `ауд. ${l.room}`, l.condition] {[l.type, l.teacher, whereLabel(l, "ауд."), l.condition]
.filter(Boolean) .filter(Boolean)
.join(" · ")} .join(" · ")}
</span> </span>
@@ -452,7 +476,7 @@ function ChangesList({ record }: { record: ChangeRecord }) {
<small>{whenLabel(l)}</small> <small>{whenLabel(l)}</small>
<b>{l.subject}</b> <b>{l.subject}</b>
<span className="change-field"> <span className="change-field">
{[l.type, l.teacher, l.room && `ауд. ${l.room}`, l.condition] {[l.type, l.teacher, whereLabel(l, "ауд."), l.condition]
.filter(Boolean) .filter(Boolean)
.join(" · ")} .join(" · ")}
</span> </span>
@@ -998,6 +1022,15 @@ export default function App() {
return list.filter((l) => l.day === d && occursOn(l, iso) !== false); return list.filter((l) => l.day === d && occursOn(l, iso) !== false);
}; };
const dayLessons = lessonsOn(visible, day); const dayLessons = lessonsOn(visible, day);
const subgroupLabels = schedule ? subgroupsOf(schedule, group) : [];
/** "Занятия в Полоцке" for the shown lessons of a weekday; none on a day without them. */
const placesOfDay = (d: number, lessons: Lesson[]) =>
lessons.length
? placesFor(
placesOn(schedule?.places, group, subgroupLabels, d, calendarMode ? dateInWeek(week!, d) : null),
prefs?.subgroup,
)
: [];
const hiddenToday = lessonsOn(hiddenLessons, day).length; const hiddenToday = lessonsOn(hiddenLessons, day).length;
const hiddenThisWeek = DAYS.reduce( const hiddenThisWeek = DAYS.reduce(
(sum, _, i) => sum + lessonsOn(hiddenLessons, i + 1).length, (sum, _, i) => sum + lessonsOn(hiddenLessons, i + 1).length,
@@ -1638,10 +1671,13 @@ export default function App() {
}} }}
> >
{dayLessons.length ? ( {dayLessons.length ? (
<ScheduleList <>
lessons={dayLessons} <DayPlaces places={placesOfDay(day, dayLessons)} />
ctx={listContext(dayIsToday)} <ScheduleList
/> lessons={dayLessons}
ctx={listContext(dayIsToday)}
/>
</>
) : ( ) : (
<div className="empty-state"> <div className="empty-state">
<Icon <Icon
@@ -1714,6 +1750,7 @@ export default function App() {
{name} {name}
{iso && <small>{longDate(iso)}</small>} {iso && <small>{longDate(iso)}</small>}
</h2> </h2>
<DayPlaces places={placesOfDay(d, lessons)} />
<ScheduleList <ScheduleList
lessons={lessons} lessons={lessons}
ctx={listContext( ctx={listContext(
@@ -2091,7 +2128,7 @@ export default function App() {
</small> </small>
<b>{l.subject}</b> <b>{l.subject}</b>
<span> <span>
{l.condition} {l.teacher} {l.room && `· ауд. ${l.room}`} {l.condition} {l.teacher} {(l.room || l.place) && `· ${whereLabel(l, "ауд.")}`}
</span> </span>
</div> </div>
))} ))}
@@ -2169,6 +2206,13 @@ export default function App() {
<small>Аудитория</small> <small>Аудитория</small>
<b>{details.room || "Не указана"}</b> <b>{details.room || "Не указана"}</b>
</span> </span>
{details.place && (
<span>
<Icon name="location_city" size={20} />
<small>Город</small>
<b>{details.place}</b>
</span>
)}
</div> </div>
{calendar && detailDates && !details.date && hasWeekLimit(details) && ( {calendar && detailDates && !details.date && hasWeekLimit(details) && (
<section className="detail-weeks" aria-label="Недели занятия"> <section className="detail-weeks" aria-label="Недели занятия">

View File

@@ -35,6 +35,8 @@ interface DayLesson {
teachers: string[]; teachers: string[];
rooms: string[]; rooms: string[];
moved: boolean; moved: boolean;
/** "Полоцк", when the document names the town (API 14). */
place?: string | null;
distance: boolean; distance: boolean;
groups: string[]; groups: string[];
documents: { id: string; name: string }[]; documents: { id: string; name: string }[];
@@ -460,10 +462,10 @@ export default function Search({
{l.groups.join(", ")} {l.groups.join(", ")}
</span> </span>
{target.kind === "teacher" ? ( {target.kind === "teacher" ? (
l.rooms.length > 0 && ( (l.rooms.length > 0 || l.place) && (
<span> <span>
<Icon name="location_on" size={20} /> <Icon name="location_on" size={20} />
Ауд. {l.rooms.join(", ")} {[l.rooms.length > 0 && `Ауд. ${l.rooms.join(", ")}`, l.place].filter(Boolean).join(" · ")}
</span> </span>
) )
) : ( ) : (

View File

@@ -18,6 +18,7 @@ import Groups from "@material-symbols/svg-400/rounded/groups.svg?react";
import GroupsFill from "@material-symbols/svg-400/rounded/groups-fill.svg?react"; import GroupsFill from "@material-symbols/svg-400/rounded/groups-fill.svg?react";
import Info from "@material-symbols/svg-400/rounded/info.svg?react"; import Info from "@material-symbols/svg-400/rounded/info.svg?react";
import KeyboardArrowDown from "@material-symbols/svg-400/rounded/keyboard_arrow_down.svg?react"; import KeyboardArrowDown from "@material-symbols/svg-400/rounded/keyboard_arrow_down.svg?react";
import LocationCity from "@material-symbols/svg-400/rounded/location_city.svg?react";
import LocationOn from "@material-symbols/svg-400/rounded/location_on.svg?react"; import LocationOn from "@material-symbols/svg-400/rounded/location_on.svg?react";
import MobileArrowDown from "@material-symbols/svg-400/rounded/mobile_arrow_down.svg?react"; import MobileArrowDown from "@material-symbols/svg-400/rounded/mobile_arrow_down.svg?react";
import MoreHoriz from "@material-symbols/svg-400/rounded/more_horiz.svg?react"; import MoreHoriz from "@material-symbols/svg-400/rounded/more_horiz.svg?react";
@@ -66,6 +67,7 @@ const icons = {
groups: Groups, groups: Groups,
info: Info, info: Info,
keyboard_arrow_down: KeyboardArrowDown, keyboard_arrow_down: KeyboardArrowDown,
location_city: LocationCity,
location_on: LocationOn, location_on: LocationOn,
mobile_arrow_down: MobileArrowDown, mobile_arrow_down: MobileArrowDown,
more_horiz: MoreHoriz, more_horiz: MoreHoriz,

View File

@@ -610,6 +610,39 @@ a {
display: grid; display: grid;
gap: 32px; gap: 32px;
} }
/* "Занятия в Полоцке": the day's town above its lessons, tonal like the update banners. */
.day-places {
display: grid;
gap: 12px;
margin-bottom: 24px;
padding: 16px 20px;
border-radius: var(--md-sys-shape-corner-large-increased);
background: var(--md-sys-color-tertiary-container);
color: var(--md-sys-color-on-tertiary-container);
}
.day-places p {
display: flex;
align-items: center;
gap: 16px;
margin: 0;
}
.day-places span {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.day-places strong {
font-size: var(--md-sys-typescale-title-medium-size);
font-weight: 600;
line-height: 1.3;
}
.day-places small {
font-size: var(--md-sys-typescale-body-medium-size);
}
.week-day > .day-places {
margin-top: 16px;
}
.day-pane[data-slide] { .day-pane[data-slide] {
animation: day-enter var(--md-sys-motion-duration-medium) animation: day-enter var(--md-sys-motion-duration-medium)
var(--md-sys-motion-easing-emphasized-decelerate); var(--md-sys-motion-easing-emphasized-decelerate);

View File

@@ -1,4 +1,5 @@
import type { WeekRange } from "../shared/weeks"; import type { WeekRange } from "../shared/weeks";
import type { PlaceNote } from "../shared/places";
export interface Lesson { export interface Lesson {
id: string; id: string;
day: number; day: number;
@@ -9,6 +10,8 @@ export interface Lesson {
subject: string; subject: string;
teacher: string; teacher: string;
room: string; room: string;
/** "Полоцк": the town, when the cell or the day's note names one (parser 14). */
place?: string;
type: string; type: string;
condition: string; condition: string;
groups: string[]; groups: string[];
@@ -24,6 +27,8 @@ export interface Schedule {
id: string; id: string;
groups: string[]; groups: string[];
lessons: Lesson[]; lessons: Lesson[];
/** "Занятия в Полоцке": days of a group or subgroup in another town, not lessons. */
places?: PlaceNote[];
notes: string[]; notes: string[];
warnings: string[]; warnings: string[];
source: { source: {

144
test/places.test.js Normal file
View File

@@ -0,0 +1,144 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import AdmZip from "adm-zip";
import { parseDocx } from "../server/parser.js";
import { placeNote, lessonPlace } from "../server/places.js";
import { placesOn, placesFor } from "../shared/places.js";
import { parseWeeksDocx, parseBellsDocx } from "../server/calendar.js";
import { mergeWeekTables } from "../server/source.js";
import { timetable } from "../server/timetable.js";
// "Занятия в Полоцке" over a day's first row, as 24-ИЯ (РА)и-4…6 write it: no lesson, but the
// town of that day's lessons in the column.
const fixture = (name) => readFileSync(new URL(`./fixtures/${name}.docx`, import.meta.url));
const calendar = {
...mergeWeekTables([parseWeeksDocx(fixture("weeks"))]),
bells: parseBellsDocx(fixture("bells")),
source: {},
};
const escape = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;");
const cell = (text, span = 1) =>
`<w:tc><w:tcPr>${span > 1 ? `<w:gridSpan w:val="${span}"/>` : ""}</w:tcPr>${text
.split("\n")
.map((line) => `<w:p><w:r><w:t>${escape(line)}</w:t></w:r></w:p>`)
.join("")}</w:tc>`;
const docx = (rows) => {
const zip = new AdmZip();
zip.addFile(
"word/document.xml",
Buffer.from(
`<?xml version="1.0" encoding="UTF-8"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>` +
`<w:p><w:r><w:t>Расписание. Осенний семестр 2026/2027 учебного года</w:t></w:r></w:p>` +
`<w:tbl>${rows.map((r) => `<w:tr>${r.join("")}</w:tr>`).join("")}</w:tbl></w:body></w:document>`,
),
);
return zip.toBuffer();
};
const schedule = parseDocx(
docx([
[cell("Дни"), cell("Часы"), cell("26-ИЯи-1", 2), cell("26-ИЯи-2", 2)],
[
cell("Вторник"),
cell("8:30-9:55"),
cell("Занятия в Полоцке"),
cell("Занятия в Полоцке\nФункциональная грамматика (п.з.) Иванова А.А. 249Ж"),
cell("Педагогика (л.) Петров П.П. 412н", 2),
],
[cell(""), cell("10:05-11:30"), cell("Педагогика (п.з.) Сидоров С.С. 205Г", 2), cell("Философия (п.з.) Козлов К.К. 510н", 2)],
[
cell("Среда"),
cell("8:30-9:55"),
cell("Латинский язык (п.з.) Нестер Н.В. 246(Новополоцк)", 2),
cell("г.Новополоцк\nРусский язык как иностранный (п.з.) Кириллова В.С. 533н"),
cell(""),
],
[cell("Четверг"), cell("8:30-9:55"), cell("История Полоцка (л.) Борун Е.Н. 100", 4)],
]),
{ name: "26-ИЯи.docx", year: "2026-2027" },
);
const file = { id: "places", name: "26-ИЯи.docx" };
const lesson = (day, start, group, subject) =>
schedule.lessons.find((l) => l.day === day && l.start === start && l.groups.includes(group) && l.subject.startsWith(subject));
test("Места: «Занятия в Полоцке» — не занятие, а город дня в столбце", () => {
assert.ok(!schedule.lessons.some((l) => /Занятия в/.test(l.subject)), "заметка не становится занятием");
assert.deepEqual(
schedule.places.map((p) => [p.day, p.groups.join(), p.subgroups.map((s) => s.label).join(), p.place, p.text]),
[
[2, "26-ИЯи-1", "Подгруппа 1", "Полоцк", "Занятия в Полоцке"],
[2, "26-ИЯи-1", "Подгруппа 2", "Полоцк", "Занятия в Полоцке"],
],
);
// The lesson under the note, in the same cell.
const grammar = lesson(2, "08:30", "26-ИЯи-1", "Функциональная грамматика");
assert.equal(grammar.place, "Полоцк");
assert.equal(grammar.room, "249Ж");
// A lesson of the whole group: both subgroups have the note.
assert.equal(lesson(2, "10:05", "26-ИЯи-1", "Педагогика").place, "Полоцк");
// Another group the same day stays where it was.
assert.equal(lesson(2, "08:30", "26-ИЯи-2", "Педагогика").place, undefined);
assert.equal(lesson(2, "10:05", "26-ИЯи-2", "Философия").place, undefined);
});
test("Места: город в ячейке занятия — отдельной строкой или после аудитории", () => {
const latin = lesson(3, "08:30", "26-ИЯи-1", "Латинский язык");
assert.equal(latin.room, "246");
assert.equal(latin.place, "Новополоцк");
const russian = lesson(3, "08:30", "26-ИЯи-2", "Русский язык");
assert.equal(russian.subject, "Русский язык как иностранный");
assert.equal(russian.place, "Новополоцк");
// A subject that names a town is no place.
const history = lesson(4, "08:30", "26-ИЯи-1", "История Полоцка");
assert.equal(history.subject, "История Полоцка");
assert.equal(history.place, undefined);
});
test("Места: разбор заметок и строк с городом", () => {
assert.deepEqual(placeNote("Занятия в Полоцке"), { text: "Занятия в Полоцке", place: "Полоцк", rest: "" });
assert.deepEqual(placeNote("Занятия в г. Новополоцк\nИнформационный час ауд.В0-02"), {
text: "Занятия в г. Новополоцк",
place: "Новополоцк",
rest: "Информационный час ауд.В0-02",
});
assert.equal(placeNote("Занятия в Междуречье.").place, "Междуречье");
assert.equal(placeNote("Занятия в Полоцке Философия (л.)").rest, "Философия (л.)");
assert.equal(placeNote("История Полоцка (л.)"), null);
assert.equal(placeNote("Занятия в ауд. 412н"), null);
assert.deepEqual(lessonPlace("121 (г.Новополоцк)"), { place: "Новополоцк", rest: "121" });
// A venue after the town stays in the text.
assert.deepEqual(lessonPlace("г. Полоцк, Дом офицеров"), { place: "Полоцк", rest: "г. Полоцк, Дом офицеров" });
assert.deepEqual(lessonPlace("Философия (л.) Борун Е.Н. 100"), { place: null, rest: "Философия (л.) Борун Е.Н. 100" });
});
test("Места: заметки дня для подгруппы — общие для сервера и PWA", () => {
const notes = [
{ day: 4, date: null, groups: ["26-ИЯи-2"], subgroups: [{ group: "26-ИЯи-2", label: "Подгруппа 1" }], place: "Новополоцк", text: "Занятия в Новополоцке" },
];
const labels = ["Подгруппа 1", "Подгруппа 2"];
const one = placesOn(notes, "26-ИЯи-2", labels, 4);
assert.deepEqual(one, [{ place: "Новополоцк", text: "Занятия в Новополоцке", subgroup: "Подгруппа 1" }]);
// All subgroups shown: the note says whose it is; the first subgroup: their own day.
assert.deepEqual(placesFor(one, null), one);
assert.deepEqual(placesFor(one, "Подгруппа 1"), [{ place: "Новополоцк", text: "Занятия в Новополоцке", subgroup: null }]);
assert.deepEqual(placesFor(one, "Подгруппа 2"), []);
assert.deepEqual(placesOn(notes, "26-ИЯи-1", labels, 4), []);
// Both subgroups of 26-ИЯи-1 on Tuesday: one note for the whole group.
assert.deepEqual(placesOn(schedule.places, "26-ИЯи-1", labels, 2), [{ place: "Полоцк", text: "Занятия в Полоцке", subgroup: null }]);
});
test("Места в /api/timetable: город дня и занятия", () => {
// Tuesday 13 and Wednesday 14 October 2026.
const one = timetable({ file, schedule, calendar, group: "26-ИЯи-1", from: "2026-10-13", days: 2 });
const [tuesday, wednesday] = one.days;
// The note in both subgroups is the whole group's.
assert.deepEqual(tuesday.places, [{ place: "Полоцк", text: "Занятия в Полоцке", subgroup: null }]);
assert.ok(tuesday.lessons.every((l) => l.place === "Полоцк"));
assert.equal(wednesday.places, undefined);
assert.equal(wednesday.lessons[0].place, "Новополоцк");
assert.deepEqual(one.weekly.find((d) => d.day === 2).places, tuesday.places);
const two = timetable({ file, schedule, calendar, group: "26-ИЯи-2", from: "2026-10-13", days: 1 });
assert.equal(two.days[0].places, undefined);
assert.ok(two.days[0].lessons.every((l) => l.place === null));
});