From 10c85e3bb9a22f2a20542477e9a238f600a116d3 Mon Sep 17 00:00:00 2001 From: Zakhar Timoshenko Date: Wed, 21 Oct 2026 01:26:54 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20read=20"=D0=97=D0=B0=D0=BD=D1=8F=D1=82?= =?UTF-8?q?=D0=B8=D1=8F=20=D0=B2=20=D0=9F=D0=BE=D0=BB=D0=BE=D1=86=D0=BA?= =?UTF-8?q?=D0=B5"=20as=20a=20day's=20town,=20show=20it=20in=20all=20clien?= =?UTF-8?q?ts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .claude/launch.json | 6 + README.md | 12 +- .../org/xtimms/psu/schedule/data/Changes.kt | 1 + .../org/xtimms/psu/schedule/data/Models.kt | 30 +++- .../xtimms/psu/schedule/data/Repository.kt | 10 ++ .../xtimms/psu/schedule/notify/Reminders.kt | 8 +- .../psu/schedule/tile/LessonTileService.kt | 2 +- .../xtimms/psu/schedule/ui/CalendarScreen.kt | 5 +- .../org/xtimms/psu/schedule/ui/Changes.kt | 3 +- .../org/xtimms/psu/schedule/ui/HomeScreen.kt | 12 +- .../org/xtimms/psu/schedule/ui/Places.kt | 80 ++++++++++ .../xtimms/psu/schedule/ui/ScheduleScreen.kt | 21 ++- .../xtimms/psu/schedule/ui/SearchScreen.kt | 1 + .../xtimms/psu/schedule/ui/SubjectsScreen.kt | 5 +- .../org/xtimms/psu/schedule/ui/Tabletop.kt | 16 +- .../org/xtimms/psu/schedule/ui/WeekGrid.kt | 10 +- .../psu/schedule/widget/NextLessonWidget.kt | 12 +- .../xtimms/psu/schedule/widget/TodayWidget.kt | 8 +- .../xtimms/psu/schedule/widget/Upcoming.kt | 3 + ios/PSUSchedule/AppModel.swift | 5 + ios/PSUSchedule/Reminders.swift | 5 +- ios/PSUSchedule/Views/CalendarView.swift | 1 + ios/PSUSchedule/Views/ChangesView.swift | 2 +- ios/PSUSchedule/Views/Components.swift | 49 ++++++ ios/PSUSchedule/Views/HomeView.swift | 9 +- ios/PSUSchedule/Views/LessonViews.swift | 12 +- ios/PSUSchedule/Views/ScheduleView.swift | 2 + ios/PSUSchedule/Views/SearchView.swift | 2 +- ios/PSUSchedule/Views/SubjectsView.swift | 4 +- ios/PSUSchedule/Views/WeekGrid.swift | 13 +- ios/Shared/Changes.swift | 1 + ios/Shared/Models.swift | 44 ++++++ ios/Shared/Schedule.swift | 7 + ios/Widgets/Widgets.swift | 2 +- scripts/snapshot-lines.js | 12 +- server/directory.js | 1 + server/index.js | 5 +- server/parser.js | 59 ++++--- server/places.js | 83 ++++++++++ server/timetable.js | 13 +- shared/lessons.d.ts | 1 + shared/lessons.js | 2 + shared/places.d.ts | 23 +++ shared/places.js | 34 +++++ src/App.tsx | 62 ++++++-- src/Search.tsx | 6 +- src/icons.tsx | 2 + src/styles.css | 33 ++++ src/types.ts | 5 + test/places.test.js | 144 ++++++++++++++++++ 50 files changed, 808 insertions(+), 80 deletions(-) create mode 100644 android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/Places.kt create mode 100644 server/places.js create mode 100644 shared/places.d.ts create mode 100644 shared/places.js create mode 100644 test/places.test.js diff --git a/.claude/launch.json b/.claude/launch.json index 43df41e..9b8e36d 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -12,6 +12,12 @@ "runtimeExecutable": "env", "runtimeArgs": ["PORT=3002", "node", "--watch", "server/index.js"], "port": 3002 + }, + { + "name": "vite-docker", + "runtimeExecutable": "env", + "runtimeArgs": ["API_PORT=3010", "npx", "vite", "--host", "127.0.0.1", "--port", "5174"], + "port": 5174 } ] } diff --git a/README.md b/README.md index 25af2d6..aa1a0df 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ Vite работает на http://127.0.0.1:5173 и проксирует `/api` - Отдельные карточки для альтернативных занятий и условий по неделям. Сохраняются исходный текст, цвет ячейки, подгруппы, даты и ограничения. - Учебные недели по таблице университета: переключатель недель с датами, показ только занятий выбранной недели, статус занятия («ещё 4 занятия · до 27 нояб.», «начнётся 20 нояб.») и сетка недель в деталях. Режим «Все строки Word» показывает документ как есть. - Номера пар и текущая пара («Идёт · ещё 25 мин») по расписанию звонков. +- **Занятия в другом городе.** Если в Word над днём написано «Занятия в Полоцке», над парами этого дня — в виде «День» и «Неделя» — стоит карточка «Занятия в Полоцке · пометка в Word на этот день» (с подгруппой, если заметка только её). В карточке пары город стоит рядом с аудиторией («Ауд. 249Ж · Полоцк»), в деталях — строка «Город». Сохранённые раньше расписания перечитываются по версии парсера и получают города сами. - «Что изменилось»: когда университет перепубликует документ, приложение сравнивает новую версию с сохранённой и показывает для вашей группы изменённые поля (аудитория, время, преподаватель, недели), добавленные и удалённые занятия. Последний список изменений открывается из меню. - Моя подгруппа и скрытые занятия: в «Группах» можно выбрать подгруппу — занятия другой подгруппы пропадут; любое занятие можно скрыть из его карточки и вернуть кнопкой «Скрыто · показать». Настройки хранятся только в этом браузере. - **Поиск по университету** (вкладка «Поиск»): преподаватель или аудитория — занятия на выбранный день по всем расписаниям каталога; свободные аудитории на дату и пару. Сервер разбирает все документы каталога в фоне (первый раз около 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») по‑прежнему находит группу. +**Занятия в другом городе** (`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. PDF, Excel, изображения и сканы пока не импортируются. Они не выдаются за распознанное расписание. @@ -241,6 +249,7 @@ DOMAIN=schedule.example.by docker compose --profile https up -d --build - Планы: задания и заметки по дням — к занятию или общие, без привязки к паре, на выбранный день, просроченные сверху, «+» прикрепляет задание к ближайшему занятию. Три макета, как в School Planner: «Повестка дня» (все дни с липкими заголовками и полосой недели, которая следует за списком), «Список» (только дни с делами) и «Доска» (колонки дней). В настройках вида — пары дня и выполненные задания. - Календарь: месяц с отметками дней с парами и с планами, зелёные недели подсвечены; под ним лист с планами и парами выбранного дня. Лист можно поднять — месяц сворачивается до выбранной недели. - Обратный отсчёт до сессии в «Обзоре»: дни до ближайшего экзамена или зачёта из `/api/exams` (консультации — только в списке), по нажатию — вся сессия. Пока университет не опубликовал сессию, отсчёт идёт до конца семестра. Экзамены кэшируются на устройстве. +- **Занятия в другом городе.** Если в Word над днём написано «Занятия в Полоцке», над парами дня появляется карточка «Занятия в Полоцке · пометка в Word на этот день» — на странице дня, в «Календаре», в «Обзоре» (сегодня и ближайший день) и на подставке «ноутбука», а в недельной сетке под датой — метка «Полоцк». Если заметка относится к одной подгруппе, а выбраны все, в карточке указана подгруппа. В карточках пар город стоит рядом с аудиторией («249Ж · Полоцк»), в подробностях — строка «Город»; так же — в виджетах, плитке и напоминаниях. Раньше такая заметка показывалась отдельной парой «Занятия в Полоцке». - **Группа, которой больше нет.** Если сервер отвечает `group_not_found` или `document_not_found` — группу разделили («24‑ИЯ(РА)» стала «24‑ИЯ (РА)и‑1»…), или документ ушёл из каталога, — плашка с текстом сервера предлагает «Выбрать группу». Сохранённая копия остаётся на экране, а устаревшая группа убирается из недавних. - **Изменения в расписании.** Как в PWA: когда университет перезаливает документ группы, приложение сравнивает новое расписание с копией на устройстве. В «Обзоре» появляется карточка «Расписание обновлено · N изменений» с кнопкой «Показать», а в списке видно, что изменилось, что добавлено и что удалено, например «Аудитория: 374 → 375». Карточка показывается до просмотра или закрытия, последний список остаётся в настройках («Последние изменения»). Сравнение срабатывает и при фоновом обновлении (`SyncWorker`, раз в 6 часов). Если изменения нашлись в фоне, приходит уведомление «Расписание 23-АРХ изменилось» с первыми изменениями, например «Проектирование общественных зданий: аудитория 374 → 375». Уведомление локальное, сервер push для него не нужен. Его можно выключить в настройках («Изменения в расписании», по умолчанию включено), а на Android 13+ нужно разрешение на уведомления. Считается только новая версия того же файла: смена версии парсера или переход на другой документ изменениями не считаются. Сравниваются строки Word по дням недели и разовые занятия со своей датой, а не каждая рассчитанная дата. - Задания и заметки к паре: из карточки занятия — к этой паре или к следующей паре того же предмета. Задания отмечаются выполненными, просроченные подсвечиваются; список — в «Обзоре», по предмету — в «Предметах», точка на блоке в недельной сетке. Хранятся только на устройстве (`notes.json`) и привязаны к группе. @@ -259,7 +268,7 @@ DOMAIN=schedule.example.by docker compose --profile https up -d --build - **В «Планах»** они видны всегда, даже с выключенными «Парами дня». - **В календаре** день с экзаменом или зачётом отмечен янтарным квадратом, а точка планов стала фиолетовой. -Даты считает сервер: `GET /api/timetable?doc=&group=<группа>&from=ГГГГ-ММ-ДД&days=N` (до 62 дней) или с `&range=semester` — весь текущий семестр от первого до последнего дня; приложение хранит его целиком и работает с ним без сети. Ответ содержит дни с неделями, номерами пар и пометкой `uncertain` для нераспознанных пометок, плюс строки Word (`weekly`) и подгруппы. Поэтому правила белых и зелёных недель в Kotlin не дублируются. +Даты считает сервер: `GET /api/timetable?doc=&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): @@ -276,6 +285,7 @@ cd android - **Онбординг:** приветствие (три стеклянные «капли» сливаются в одну), затем группа, подгруппа (только если группа делится на подгруппы) и напоминания. Разрешение на уведомления спрашивается на последнем шаге. - **Стеклянная панель вкладок:** три раздела — «Обзор», «Расписание», «Планы» — и поиск отдельной круглой кнопкой справа (роль `.search`). Нажатие превращает кнопку в поле ввода над клавиатурой, как в приложениях Apple (`tabViewSearchActivation(.searchTabSelection)`). «Календарь» открывается кнопкой в «Планах», «Предметы» — карточкой в «Обзоре». На iPad панель превращается в боковое меню. Группа и настройки открываются из панели «Обзора»; тема меняется сразу во всех окнах, включая открытые настройки. +- **Занятия в другом городе:** как в Android и PWA. Над парами дня, в «Календаре» и в «Обзоре» (сегодня и ближайший день) стоит карточка «Занятия в Полоцке», в недельной сетке под датой — метка «Полоцк». В карточках пар город стоит рядом с аудиторией, в подробностях — строка «Город»; так же — в виджетах и напоминаниях. - **Обзор:** неделя (сколько пар осталось, чип на каждый день), обратный отсчёт до сессии или конца семестра, следующие занятия с отсчётом «Через 20 мин», посещаемость одним касанием, задания и ближайший учебный день. На iPad рядом показывается недельная сетка. - **Изменения в расписании:** как в Android и PWA — карточка «Расписание обновлено · N изменений» в «Обзоре» после перезаливки документа, по «Показать» — лист с изменёнными, добавленными и удалёнными занятиями; последний список — в настройках. Сравнение срабатывает и при фоновом обновлении `BGAppRefreshTask`: тогда приходит локальное уведомление (настройка «Изменения в расписании»). Когда запускать фоновое обновление, решает iOS, поэтому уведомление может прийти через несколько часов, а если приложение смахнули из переключателя — только после следующего открытия. - **Расписание:** дни листаются в сторону под плавающей стеклянной лентой недели. На зелёных неделях стекло окрашено зелёным. Есть недельная сетка по часам и строки Word, если семестр не найден. В подробностях пары: время по звонкам, посещаемость, задания, исходная запись Word и скрытие таких занятий. diff --git a/android/app/src/main/kotlin/org/xtimms/psu/schedule/data/Changes.kt b/android/app/src/main/kotlin/org/xtimms/psu/schedule/data/Changes.kt index e5d066c..c22f7b9 100644 --- a/android/app/src/main/kotlin/org/xtimms/psu/schedule/data/Changes.kt +++ b/android/app/src/main/kotlin/org/xtimms/psu/schedule/data/Changes.kt @@ -68,6 +68,7 @@ private fun fields(a: ChangedLesson, b: ChangedLesson): List { ("Вид занятия" to (a.lesson.type to b.lesson.type)), ("Преподаватель" to (a.lesson.teachers.joinToString(", ") to b.lesson.teachers.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() } .map { (label, values) -> FieldChange(label, shown(values.first), shown(values.second)) } } diff --git a/android/app/src/main/kotlin/org/xtimms/psu/schedule/data/Models.kt b/android/app/src/main/kotlin/org/xtimms/psu/schedule/data/Models.kt index 4e36577..8b45b4f 100644 --- a/android/app/src/main/kotlin/org/xtimms/psu/schedule/data/Models.kt +++ b/android/app/src/main/kotlin/org/xtimms/psu/schedule/data/Models.kt @@ -40,10 +40,29 @@ data class Week(val n: Int, val color: String) { } @Serializable -data class Day(val date: String, val weekday: Int, val week: Week? = null, val lessons: List = emptyList()) +data class Day( + val date: String, + val weekday: Int, + val week: Week? = null, + val lessons: List = emptyList(), + val places: List = emptyList(), +) @Serializable -data class WeeklyDay(val day: Int, val lessons: List = emptyList()) +data class WeeklyDay(val day: Int, val lessons: List = emptyList(), val places: List = 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.forSubgroup(subgroup: String?): List = + filter { subgroup == null || it.subgroup == null || it.subgroup == subgroup } + .map { if (subgroup != null) it.copy(subgroup = null) else it } + .distinct() @Serializable data class Lesson( @@ -56,6 +75,8 @@ data class Lesson( val teachers: List = emptyList(), val rooms: List = emptyList(), 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 condition: String = "", 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. */ 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 data class GroupsResponse( val results: List = emptyList(), @@ -118,6 +143,7 @@ data class DirectoryLesson( val teachers: List = emptyList(), val rooms: List = emptyList(), val moved: Boolean = false, + val place: String? = null, val distance: Boolean = false, val groups: List = emptyList(), val condition: String = "", diff --git a/android/app/src/main/kotlin/org/xtimms/psu/schedule/data/Repository.kt b/android/app/src/main/kotlin/org/xtimms/psu/schedule/data/Repository.kt index cc1bf1e..2e728fd 100644 --- a/android/app/src/main/kotlin/org/xtimms/psu/schedule/data/Repository.kt +++ b/android/app/src/main/kotlin/org/xtimms/psu/schedule/data/Repository.kt @@ -114,4 +114,14 @@ class Repository(context: Context, val api: Api, private val settings: Settings) fun dayOf(timetable: Timetable, date: LocalDate): Day? = 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 = emptySet()): List { + val day = dayOf(timetable, date) ?: return emptyList() + if (day.places.isEmpty() || lessonsOn(timetable, date, subgroup, hidden).isEmpty()) return emptyList() + return day.places.forSubgroup(subgroup) + } } diff --git a/android/app/src/main/kotlin/org/xtimms/psu/schedule/notify/Reminders.kt b/android/app/src/main/kotlin/org/xtimms/psu/schedule/notify/Reminders.kt index 1538c45..15b1ba2 100644 --- a/android/app/src/main/kotlin/org/xtimms/psu/schedule/notify/Reminders.kt +++ b/android/app/src/main/kotlin/org/xtimms/psu/schedule/notify/Reminders.kt @@ -1,6 +1,7 @@ package org.xtimms.psu.schedule.notify import org.xtimms.psu.schedule.data.hideKey +import org.xtimms.psu.schedule.data.withPlace import android.Manifest import android.app.AlarmManager import android.app.NotificationChannel @@ -85,11 +86,14 @@ object Reminders { if (at.isBefore(now) || begins.isAfter(horizon) || slot >= MAX) continue val first = lessons.first() 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 { append(start) first.pair?.let { append(" · $it пара") } - if (place.isNotEmpty()) append(" · ауд. $place") + place?.let { append(" · $it") } if (lessons.any { it.uncertain }) append(" · сверьте недели") } val pending = intent(context, slot) { diff --git a/android/app/src/main/kotlin/org/xtimms/psu/schedule/tile/LessonTileService.kt b/android/app/src/main/kotlin/org/xtimms/psu/schedule/tile/LessonTileService.kt index 4153bdb..c8aba88 100644 --- a/android/app/src/main/kotlin/org/xtimms/psu/schedule/tile/LessonTileService.kt +++ b/android/app/src/main/kotlin/org/xtimms/psu/schedule/tile/LessonTileService.kt @@ -32,7 +32,7 @@ class LessonTileService : TileService() { tile.state = Tile.STATE_INACTIVE if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) tile.subtitle = "на ближайшую неделю" } else { - val room = next.rooms.takeIf { it.isNotEmpty() }?.let { "ауд. $it" } + val room = next.where tile.label = listOfNotNull(next.whenLabel(), room).joinToString(" · ") 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 diff --git a/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/CalendarScreen.kt b/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/CalendarScreen.kt index d9b3327..1993be9 100644 --- a/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/CalendarScreen.kt +++ b/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/CalendarScreen.kt @@ -95,6 +95,7 @@ import org.xtimms.psu.schedule.data.longLabel import org.xtimms.psu.schedule.data.monday import org.xtimms.psu.schedule.data.today import org.xtimms.psu.schedule.data.weekdayName +import org.xtimms.psu.schedule.data.withPlace import org.xtimms.psu.schedule.ui.theme.page import java.time.LocalDate 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 known = t?.days?.any { it.date == date.toString() } == true 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 } } if (plans.isEmpty() && lessons.isEmpty()) { @@ -519,6 +521,7 @@ private fun DayAgendaContent( item(key = "lessons") { AnimatedItem { 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) -> item(key = "${slot.start}|${l.key}") { val color = subjectColor(l.subject) @@ -532,7 +535,7 @@ private fun DayAgendaContent( overColor = if (session != null) sessionColors().accent else null, title = l.subject, 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(), ).joinToString(" · ").ifEmpty { null }, last = i == lessons.lastIndex, diff --git a/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/Changes.kt b/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/Changes.kt index c510d26..0f718ac 100644 --- a/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/Changes.kt +++ b/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/Changes.kt @@ -36,6 +36,7 @@ import org.xtimms.psu.schedule.data.longLabel import org.xtimms.psu.schedule.data.parseDate import org.xtimms.psu.schedule.data.plural import org.xtimms.psu.schedule.data.shortWeekday +import org.xtimms.psu.schedule.data.withPlace import org.xtimms.psu.schedule.ui.theme.card import java.time.Instant import java.time.ZonedDateTime @@ -106,7 +107,7 @@ fun ChangesSheet(record: ScheduleChanges) { if (record.added.isNotEmpty()) { Heading("Добавлено · ${record.added.size}", modifier = Modifier.padding(top = 8.dp)) 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()) { diff --git a/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/HomeScreen.kt b/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/HomeScreen.kt index 69f902e..4ceb1dd 100644 --- a/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/HomeScreen.kt +++ b/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/HomeScreen.kt @@ -86,6 +86,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.coroutines.delay +import org.xtimms.psu.schedule.data.DayPlace import org.xtimms.psu.schedule.data.Lesson import org.xtimms.psu.schedule.data.Timetable 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.weekSpan import org.xtimms.psu.schedule.data.weekdayName +import org.xtimms.psu.schedule.data.withPlace import java.time.Duration import java.time.LocalDate 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 = "session") { AnimatedItem { SessionCard(vm, t, onOpen = { session = true }) } } 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 = "tasks") { AnimatedItem { TasksCard(vm, onEdit = { editing = it }, onAll = nav.openPlans) } } 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") { 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 }) } } DayButton { nav.openDay(day) } } @@ -444,7 +447,7 @@ private fun DayTitle(label: String, date: LocalDate) { } @Composable -private fun TodayCard(slots: List, clock: LocalTime, onOpen: (Lesson) -> Unit, onDay: () -> Unit) { +private fun TodayCard(slots: List, places: List, clock: LocalTime, onOpen: (Lesson) -> Unit, onDay: () -> Unit) { val left = slots.filter { it.end.isAfter(clock) } SectionCard(Icons.Rounded.Schedule, "Следующие занятия") { when { @@ -454,6 +457,7 @@ private fun TodayCard(slots: List, clock: LocalTime, onOpen: (Lesson) -> U "Сегодня было ${slots.size} ${plural(slots.size, "пара", "пары", "пар")}.", ) else -> { + DayPlaces(places) val first = left.first() first.lessons.forEach { FeaturedLesson(it, first, clock, onClick = { onOpen(it) }) } 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 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) } 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( session?.label, 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(), ).joinToString(" · "), style = MaterialTheme.typography.bodyMedium, diff --git a/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/Places.kt b/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/Places.kt new file mode 100644 index 0000000..01e5870 --- /dev/null +++ b/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/Places.kt @@ -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 = + 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, 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, 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), + ) + } +} diff --git a/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/ScheduleScreen.kt b/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/ScheduleScreen.kt index 8828d12..fdfcea2 100644 --- a/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/ScheduleScreen.kt +++ b/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/ScheduleScreen.kt @@ -69,6 +69,7 @@ import androidx.compose.material.icons.rounded.ErrorOutline import androidx.compose.material.icons.rounded.EventBusy import androidx.compose.material.icons.rounded.EventRepeat 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.Person import androidx.compose.material.icons.rounded.Refresh @@ -101,13 +102,16 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.coroutines.launch 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.Timetable 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.monday import org.xtimms.psu.schedule.data.parseDate import org.xtimms.psu.schedule.data.today +import org.xtimms.psu.schedule.data.withPlace import java.time.Duration import java.time.LocalDate @@ -384,6 +388,7 @@ private fun Planner( DayPage( date = pageDate, slots = vm.slotsOn(t, pageDate, subgroup), + places = vm.placesOn(t, pageDate, subgroup), onOpen = onOpen, ) } @@ -402,6 +407,7 @@ private fun Planner( date = pageDate, week = t.days.firstOrNull { it.date == pageDate.toString() }?.week, slots = vm.slotsOn(t, pageDate, subgroup), + places = vm.placesOn(t, pageDate, subgroup), chosen = stand?.takeIf { it.first == pageDate }?.second, clock = clock, onOpen = { onOpen(it, pageDate) }, @@ -431,7 +437,7 @@ private fun Planner( } @Composable -private fun DayPage(date: LocalDate, slots: List, onOpen: (Lesson, LocalDate) -> Unit) { +private fun DayPage(date: LocalDate, slots: List, places: List, onOpen: (Lesson, LocalDate) -> Unit) { val clock = rememberClock() val isToday = date == today() val next = if (isToday) slots.firstOrNull { it.start.isAfter(clock) } else null @@ -446,6 +452,8 @@ private fun DayPage(date: LocalDate, slots: List, onOpen: (Lesson, LocalDa Empty("Занятий нет", "По таблице недель в этот день свободно — можно отдохнуть.", icon = Icons.Rounded.Weekend) } } + // "Занятия в Полоцке": the day's town before its lessons. + if (places.isNotEmpty()) item(key = "places") { AnimatedItem { DayPlaces(places) } } slots.forEach { slot -> val running = isToday && !clock.isBefore(slot.start) && clock.isBefore(slot.end) val status = when { @@ -508,10 +516,10 @@ fun LessonCard( 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)) { if (lesson.distance) Meta(Icons.Rounded.LocationOn, "Дистанционно") - else if (show.rooms && lesson.rooms.isNotEmpty()) Meta( - Icons.Rounded.LocationOn, - lesson.rooms.joinToString(", ") + if (lesson.roomMoved) " (перенос)" else "", - ) + else withPlace( + lesson.rooms.takeIf { show.rooms && it.isNotEmpty() }?.let { it.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(", ")) 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.roomMoved) "В этот день — перенос аудитории" else null, ) + lesson.place?.let { Detail(Icons.Rounded.LocationCity, "Город", it, "Так указано в Word") } Detail(Icons.Rounded.Groups, "Группы", lesson.groups.joinToString(", "), lesson.subgroup) if (lesson.uncertain) Banner( "Пометку «${lesson.condition}» не удалось сопоставить с таблицей недель. Сверьте с Word.", @@ -702,6 +711,8 @@ private fun WordRows(t: Timetable, subgroup: String?, hidden: Set, onOpe } val lessons = day.lessons.filter { subgroup == null || it.subgroup == null || it.subgroup == subgroup } .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 -> LessonCard(lesson, time = lesson.start + (lesson.end?.let { " → $it" } ?: ""), onClick = { onOpen(lesson) }) } diff --git a/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/SearchScreen.kt b/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/SearchScreen.kt index 1671d42..8008fd4 100644 --- a/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/SearchScreen.kt +++ b/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/SearchScreen.kt @@ -261,6 +261,7 @@ fun SearchScreen(vm: MainViewModel, nav: AppNav) { teachers = if (target is Target.Room) l.teachers else emptyList(), rooms = if (target is Target.Teacher) l.rooms else emptyList(), roomMoved = l.moved, + place = l.place, distance = l.distance, condition = l.condition, groups = l.groups, diff --git a/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/SubjectsScreen.kt b/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/SubjectsScreen.kt index 7e8164b..9a61676 100644 --- a/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/SubjectsScreen.kt +++ b/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/SubjectsScreen.kt @@ -12,6 +12,7 @@ import androidx.compose.material.icons.automirrored.rounded.Assignment import androidx.compose.material.icons.rounded.Add import androidx.compose.material3.FilledTonalButton import org.xtimms.psu.schedule.data.Note +import org.xtimms.psu.schedule.data.withPlace import androidx.compose.foundation.background import androidx.compose.material.icons.rounded.DateRange import androidx.compose.material.icons.rounded.Event @@ -283,7 +284,7 @@ private fun SubjectCard(s: SubjectSummary, onClick: () -> Unit) { Fact( Icons.Rounded.Event, 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, ) @@ -360,7 +361,7 @@ private fun SubjectDetails(s: SubjectSummary, vm: MainViewModel) { Text( listOfNotNull( 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 }, ).joinToString(" · "), style = MaterialTheme.typography.bodyMedium, diff --git a/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/Tabletop.kt b/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/Tabletop.kt index 1a9b80d..7e3397d 100644 --- a/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/Tabletop.kt +++ b/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/Tabletop.kt @@ -37,7 +37,9 @@ import androidx.compose.ui.semantics.selected import androidx.compose.ui.semantics.semantics 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.Lesson +import org.xtimms.psu.schedule.data.withPlace import org.xtimms.psu.schedule.data.Week import org.xtimms.psu.schedule.data.longLabel import org.xtimms.psu.schedule.data.minutesLabel @@ -65,6 +67,7 @@ fun StandDay( date: LocalDate, week: Week?, slots: List, + places: List, chosen: LocalTime?, clock: LocalTime, onOpen: (Lesson) -> Unit, @@ -86,7 +89,11 @@ fun StandDay( } Text( 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, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(bottom = 12.dp), @@ -158,14 +165,14 @@ private fun StandLesson(slot: Slot, clock: LocalTime, isToday: Boolean, onOpen: overflow = TextOverflow.Ellipsis, ) 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) } if (show.teachers && lesson.teachers.isNotEmpty()) StandMeta(Icons.Rounded.Person, lesson.teachers.joinToString(", ")) } // Parallel subgroups share the slot: the others in a line. slot.lessons.drop(1).forEach { other -> 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(" · "), style = MaterialTheme.typography.bodyMedium, maxLines = 1, @@ -271,7 +278,8 @@ fun StandLessons( color = if (past && !selected) MaterialTheme.colorScheme.onSurfaceVariant else Color.Unspecified, ) 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) } } } } diff --git a/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/WeekGrid.kt b/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/WeekGrid.kt index c4ea5cc..a3f5610 100644 --- a/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/WeekGrid.kt +++ b/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/WeekGrid.kt @@ -45,6 +45,7 @@ import org.xtimms.psu.schedule.data.longLabel import org.xtimms.psu.schedule.data.shortWeekday import org.xtimms.psu.schedule.data.today import org.xtimms.psu.schedule.data.weekdayName +import org.xtimms.psu.schedule.data.withPlace import java.time.LocalDate import java.time.LocalTime @@ -88,6 +89,7 @@ fun WeekGrid( ) { days.forEach { (d, _) -> val isToday = d == today() + val places = vm.placesOn(t, d, subgroup) Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier @@ -113,6 +115,8 @@ fun WeekGrid( 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) { val color = subjectColor(lesson.subject) 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 { lesson.distance -> "дист." - LocalDisplay.current.rooms -> lesson.rooms.joinToString(", ") - else -> "" + wide -> withPlace(rooms, lesson.place).orEmpty() + else -> rooms.ifEmpty { lesson.place.orEmpty() } } Surface( onClick = onClick, diff --git a/android/app/src/main/kotlin/org/xtimms/psu/schedule/widget/NextLessonWidget.kt b/android/app/src/main/kotlin/org/xtimms/psu/schedule/widget/NextLessonWidget.kt index 9c49d17..f4e39d3 100644 --- a/android/app/src/main/kotlin/org/xtimms/psu/schedule/widget/NextLessonWidget.kt +++ b/android/app/src/main/kotlin/org/xtimms/psu/schedule/widget/NextLessonWidget.kt @@ -69,11 +69,13 @@ class NextLessonWidget : GlanceAppWidget() { maxLines = 1, style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 15.sp, fontWeight = FontWeight.Bold), ) - if (next.rooms.isNotEmpty()) Text( - "ауд. ${next.rooms}", - maxLines = 1, - style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 13.sp), - ) + next.where?.let { where -> + Text( + where, + maxLines = 1, + style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 13.sp), + ) + } } } } diff --git a/android/app/src/main/kotlin/org/xtimms/psu/schedule/widget/TodayWidget.kt b/android/app/src/main/kotlin/org/xtimms/psu/schedule/widget/TodayWidget.kt index 5078f19..e34205c 100644 --- a/android/app/src/main/kotlin/org/xtimms/psu/schedule/widget/TodayWidget.kt +++ b/android/app/src/main/kotlin/org/xtimms/psu/schedule/widget/TodayWidget.kt @@ -34,6 +34,7 @@ import org.xtimms.psu.schedule.data.nowTime import org.xtimms.psu.schedule.data.parseTime import org.xtimms.psu.schedule.data.today import org.xtimms.psu.schedule.data.weekdayName +import org.xtimms.psu.schedule.data.withPlace import java.time.Duration import java.time.LocalDate @@ -71,8 +72,11 @@ private suspend fun state(context: Context): WidgetState { "$start · через ${minutesLabel(Duration.between(now, begins).toMinutes())}" else -> start + (l.pair?.let { " · $it пара" } ?: "") } - val place = lessons.flatMap { it.rooms }.distinct().joinToString(", ") - label to (lessons.joinToString(" / ") { it.subject } + if (place.isNotEmpty()) " · ауд. $place" else "") + val place = withPlace( + 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( timetable.group, diff --git a/android/app/src/main/kotlin/org/xtimms/psu/schedule/widget/Upcoming.kt b/android/app/src/main/kotlin/org/xtimms/psu/schedule/widget/Upcoming.kt index 075d3d3..7689b1c 100644 --- a/android/app/src/main/kotlin/org/xtimms/psu/schedule/widget/Upcoming.kt +++ b/android/app/src/main/kotlin/org/xtimms/psu/schedule/widget/Upcoming.kt @@ -8,6 +8,7 @@ import org.xtimms.psu.schedule.data.nowTime import org.xtimms.psu.schedule.data.parseTime import org.xtimms.psu.schedule.data.shortWeekday import org.xtimms.psu.schedule.data.today +import org.xtimms.psu.schedule.data.withPlace import java.time.Duration 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, val running: Boolean) { val subject get() = lessons.joinToString(" / ") { it.subject } 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". */ fun whenLabel(): String { diff --git a/ios/PSUSchedule/AppModel.swift b/ios/PSUSchedule/AppModel.swift index 939bccd..04fffeb 100644 --- a/ios/PSUSchedule/AppModel.swift +++ b/ios/PSUSchedule/AppModel.swift @@ -65,6 +65,11 @@ final class AppModel { 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. */ var changes: ScheduleChanges? { guard let t = timetable else { return nil } diff --git a/ios/PSUSchedule/Reminders.swift b/ios/PSUSchedule/Reminders.swift index 42e13da..48ea68c 100644 --- a/ios/PSUSchedule/Reminders.swift +++ b/ios/PSUSchedule/Reminders.swift @@ -35,10 +35,11 @@ enum Reminders { let content = UNMutableNotificationContent() content.title = slot.lessons.map(\.subject).joined(separator: " / ") 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 if let pair = first.pair { text += " · \(pair) пара" } - if !place.isEmpty { text += " · ауд. \(place)" } + if let place { text += " · \(place)" } if slot.lessons.contains(where: \.uncertain) { text += " · сверьте недели" } content.body = text content.sound = .default diff --git a/ios/PSUSchedule/Views/CalendarView.swift b/ios/PSUSchedule/Views/CalendarView.swift index 734cc25..19ceb6f 100644 --- a/ios/PSUSchedule/Views/CalendarView.swift +++ b/ios/PSUSchedule/Views/CalendarView.swift @@ -225,6 +225,7 @@ private struct DayAgenda: View { } if !slots.isEmpty { Text("Ваши занятия").font(.headline) + if let t = model.timetable { DayPlaces(places: model.places(t, on: date)) } ForEach(slots) { slot in ForEach(slot.lessons, id: \.key) { lesson in Button { details = LessonRef(lesson: lesson, date: date) } label: { diff --git a/ios/PSUSchedule/Views/ChangesView.swift b/ios/PSUSchedule/Views/ChangesView.swift index 5d2575d..a12a6a3 100644 --- a/ios/PSUSchedule/Views/ChangesView.swift +++ b/ios/PSUSchedule/Views/ChangesView.swift @@ -79,7 +79,7 @@ struct ChangesSheet: View { Section("Добавлено · \(record.added.count)") { ForEach(record.added, id: \.self) { row in 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]) } } } diff --git a/ios/PSUSchedule/Views/Components.swift b/ios/PSUSchedule/Views/Components.swift index 80b7a10..0a883d9 100644 --- a/ios/PSUSchedule/Views/Components.swift +++ b/ios/PSUSchedule/Views/Components.swift @@ -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. struct StatusBanner: View { @Environment(AppModel.self) private var model diff --git a/ios/PSUSchedule/Views/HomeView.swift b/ios/PSUSchedule/Views/HomeView.swift index 0161eaa..fb7e941 100644 --- a/ios/PSUSchedule/Views/HomeView.swift +++ b/ios/PSUSchedule/Views/HomeView.swift @@ -90,7 +90,7 @@ struct HomeView: View { WeekReport(timetable: t) SessionCard(timetable: t) { session = true } 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) NavigationLink(value: HomeRoute.subjects) { SubjectsLink(timetable: t) } .buttonStyle(.plain) @@ -99,6 +99,7 @@ struct HomeView: View { let (day, slots) = next DayTitle(label: day == today.adding(days: 1) ? "Завтра" : day.weekdayName, date: day) SectionCard(symbol: "calendar.badge.checkmark", title: pairs(slots.count)) { + DayPlaces(places: model.places(t, on: day)) ForEach(slots) { slot in ForEach(slot.lessons, id: \.key) { lesson in 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 { @Environment(AppModel.self) private var model var slots: [Slot] + var places: [DayPlace] var onOpen: (Lesson) -> Void var onDay: () -> Void @@ -276,6 +278,7 @@ private struct TodayCard: View { } else if left.isEmpty { Quiet(title: "Пары на сегодня закончились", text: "Сегодня было \(pairs(slots.count)).") } else { + DayPlaces(places: places) let first = left[0] ForEach(first.lessons, id: \.key) { lesson in 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) } 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: ", ")) } } Spacer(minLength: 0) diff --git a/ios/PSUSchedule/Views/LessonViews.swift b/ios/PSUSchedule/Views/LessonViews.swift index 4ca9fac..e1a9465 100644 --- a/ios/PSUSchedule/Views/LessonViews.swift +++ b/ios/PSUSchedule/Views/LessonViews.swift @@ -38,8 +38,11 @@ struct LessonCard: View { } FlowLayout(spacing: 14) { 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: ", ") + (lesson.roomMoved ? " (перенос)" : "")) + else if let room = withPlace( + 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 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 sub = lesson.subgroup, lesson.session != nil { parts.append(sub.lowercased().replacingOccurrences(of: "подгруппа", with: "подгр.")) } 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()) } return parts.joined(separator: " · ") } @@ -163,6 +166,9 @@ struct LessonDetails: View { value: lesson.distance ? "Дистанционно" : (lesson.rooms.isEmpty ? "Не указана" : lesson.rooms.joined(separator: ", ")), 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) } .card() diff --git a/ios/PSUSchedule/Views/ScheduleView.swift b/ios/PSUSchedule/Views/ScheduleView.swift index 8b328ac..112f87c 100644 --- a/ios/PSUSchedule/Views/ScheduleView.swift +++ b/ios/PSUSchedule/Views/ScheduleView.swift @@ -153,6 +153,7 @@ private struct DayPage: View { EmptyState(title: "Занятий нет", text: "По таблице недель в этот день свободно — можно отдохнуть.", symbol: "sofa") .padding(.top, 60) } + DayPlaces(places: model.places(timetable, on: date)) ForEach(slots) { slot in let running = isToday && clock >= slot.start && clock < slot.end let status: String? = running @@ -281,6 +282,7 @@ private struct WordRows: View { let lessons = day.lessons .filter { model.subgroup == nil || $0.subgroup == nil || $0.subgroup == model.subgroup } .filter { !model.hidden.contains($0.hideKey) } + if !lessons.isEmpty { DayPlaces(places: day.places.forSubgroup(model.subgroup)) } ForEach(lessons, id: \.key) { lesson in Button { onOpen(lesson) } label: { LessonCard(lesson: lesson, time: lesson.start + (lesson.end.map { " → \($0)" } ?? ""), date: nil) diff --git a/ios/PSUSchedule/Views/SearchView.swift b/ios/PSUSchedule/Views/SearchView.swift index 2ede57f..24f2da2 100644 --- a/ios/PSUSchedule/Views/SearchView.swift +++ b/ios/PSUSchedule/Views/SearchView.swift @@ -165,7 +165,7 @@ private struct DirectoryLessonCard: View { Text(lesson.start + (lesson.end.map { " → \($0)" } ?? "") + (pair.map { " · \($0) пара" } ?? "")).font(.body.monospacedDigit()) FlowLayout(spacing: 14) { 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 { Meta(symbol: "person", text: lesson.teachers.joined(separator: ", ")) } diff --git a/ios/PSUSchedule/Views/SubjectsView.swift b/ios/PSUSchedule/Views/SubjectsView.swift index 20acbb0..e4cc4a5 100644 --- a/ios/PSUSchedule/Views/SubjectsView.swift +++ b/ios/PSUSchedule/Views/SubjectsView.swift @@ -151,7 +151,7 @@ private struct SubjectCard: View { } if let first = summary.upcoming.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 { Meta(symbol: "checkmark.circle", text: "Занятий в этом семестре больше нет") } @@ -211,7 +211,7 @@ private struct SubjectDetails: View { Button { details = LessonRef(lesson: item.1, date: item.0.date) } label: { VStack(alignment: .leading, spacing: 2) { 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) } } diff --git a/ios/PSUSchedule/Views/WeekGrid.swift b/ios/PSUSchedule/Views/WeekGrid.swift index aae93ea..1c8b97d 100644 --- a/ios/PSUSchedule/Views/WeekGrid.swift +++ b/ios/PSUSchedule/Views/WeekGrid.swift @@ -21,11 +21,13 @@ struct WeekGrid: View { let today = model.today 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) ForEach(days, id: \.0) { day in let d = day.0 let isToday = d == today + let places = model.places(timetable, on: d) VStack(spacing: 2) { Text(d.shortWeekday).font(.caption.weight(.medium)).foregroundStyle(isToday ? Color.brand : .secondary) Text("\(d.day)") @@ -33,10 +35,12 @@ struct WeekGrid: View { .foregroundStyle(isToday ? Color.onBrand : .primary) .frame(width: 34, height: 34) .background(isToday ? Color.brand : .clear, in: .circle) + // "Занятия в Полоцке": the town of the whole day, under its date. + DayPlaceMark(places: places) } .frame(maxWidth: .infinity) .accessibilityElement(children: .ignore) - .accessibilityLabel(d.weekdayName + ", " + d.longLabel) + .accessibilityLabel(d.weekdayName + ", " + d.longLabel + places.map { ", " + $0.text }.joined()) } } .padding(.bottom, 8) @@ -117,7 +121,8 @@ private struct LessonBlock: View { Text(wide ? lesson.subject : abbreviate(lesson.subject)) .font(.caption.weight(.semibold)) .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) } Spacer(minLength: 0) @@ -140,6 +145,6 @@ private struct LessonBlock: View { } } .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 } ?? "")) } } diff --git a/ios/Shared/Changes.swift b/ios/Shared/Changes.swift index a822fee..fe3fedd 100644 --- a/ios/Shared/Changes.swift +++ b/ios/Shared/Changes.swift @@ -106,6 +106,7 @@ private func fields(_ a: ChangedLesson, _ b: ChangedLesson) -> [FieldChange] { ("Вид занятия", a.lesson.type, b.lesson.type), ("Преподаватель", a.lesson.teachers.joined(separator: ", "), b.lesson.teachers.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 } return pairs.filter { $0.1.trimmed != $0.2.trimmed }.map { FieldChange(label: $0.0, from: shown($0.1), to: shown($0.2)) } diff --git a/ios/Shared/Models.swift b/ios/Shared/Models.swift index 7895c95..f9b7268 100644 --- a/ios/Shared/Models.swift +++ b/ios/Shared/Models.swift @@ -51,11 +51,39 @@ struct Day: Codable, Hashable { var weekday: Int var week: Week? var lessons: [Lesson] = [] + var places: [DayPlace] = [] } struct WeeklyDay: Codable, Hashable { var day: Int 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() + 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 { @@ -68,6 +96,8 @@ struct Lesson: Codable, Hashable { var teachers: [String] = [] var rooms: [String] = [] var roomMoved: Bool = false + /** The town, when the cell or the day's note names one: "Полоцк". */ + var place: String? var distance: Bool = false var condition: String = "" var variant: String = "all" @@ -139,6 +169,7 @@ struct DirectoryLesson: Codable, Hashable { var teachers: [String] = [] var rooms: [String] = [] var moved: Bool = false + var place: String? var distance: Bool = false var groups: [String] = [] var condition: String = "" @@ -266,6 +297,7 @@ extension Day { weekday = try c.decodeIfPresent(Int.self, forKey: .weekday) ?? 0 week = try c.decodeIfPresent(Week.self, forKey: .week) 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) day = try c.decode(Int.self, forKey: .day) 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) ?? [] rooms = try c.decodeIfPresent([String].self, forKey: .rooms) ?? [] 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 condition = try c.decodeIfPresent(String.self, forKey: .condition) ?? "" variant = try c.decodeIfPresent(String.self, forKey: .variant) ?? "all" @@ -375,6 +418,7 @@ extension DirectoryLesson { teachers = try c.decodeIfPresent([String].self, forKey: .teachers) ?? [] rooms = try c.decodeIfPresent([String].self, forKey: .rooms) ?? [] 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 groups = try c.decodeIfPresent([String].self, forKey: .groups) ?? [] condition = try c.decodeIfPresent(String.self, forKey: .condition) ?? "" diff --git a/ios/Shared/Schedule.swift b/ios/Shared/Schedule.swift index 385a8aa..d4d20e8 100644 --- a/ios/Shared/Schedule.swift +++ b/ios/Shared/Schedule.swift @@ -34,6 +34,13 @@ extension Timetable { .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) -> [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) -> [Slot] { let lessons = lessons(on: date, subgroup: subgroup, hidden: hidden) var order: [String] = [] diff --git a/ios/Widgets/Widgets.swift b/ios/Widgets/Widgets.swift index 0a6ce35..0d4a834 100644 --- a/ios/Widgets/Widgets.swift +++ b/ios/Widgets/Widgets.swift @@ -70,7 +70,7 @@ private func status(_ slot: Slot, day: CivilDate, at date: Date) -> String? { private func place(_ lesson: Lesson) -> String? { 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. diff --git a/scripts/snapshot-lines.js b/scripts/snapshot-lines.js index d8e3ea9..d7e63d6 100644 --- a/scripts/snapshot-lines.js +++ b/scripts/snapshot-lines.js @@ -12,16 +12,26 @@ export const lessonLine = (l) => (l.variant === "upper" ? ", верхняя строка" : l.variant === "lower" ? ", нижняя строка" : ""), l.subject, 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.groups, ...l.subgroups.map((s) => `${s.group}/${s.label.replace("Подгруппа ", "п")}`)].join(","), ] .map((part) => String(part ?? "").replace(/\s+/g, " ").trim()) .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. */ export const snapshotOf = (schedule) => [ `# ${schedule.period} · ${schedule.groups.join(", ")}`, + ...(schedule.places || []).map(placeLine).sort(), ...schedule.lessons.map(lessonLine).sort(), ]; diff --git a/server/directory.js b/server/directory.js index 5dd41ea..ee4be1e 100644 --- a/server/directory.js +++ b/server/directory.js @@ -311,6 +311,7 @@ export function dayOf(idx, calendar, { teacher, room }, date) { teachers, rooms, moved: movedOn(entry, date), + place: l.place || null, distance: entry.distance, groups: [], documents: [], diff --git a/server/index.js b/server/index.js index e076f75..fdd5574 100644 --- a/server/index.js +++ b/server/index.js @@ -78,8 +78,9 @@ app.use("/api", (_req, res, next) => { // 10 — parserVersion in /api/catalog; // 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; -// 13 — the dashboard at /status (password), /api/health only says alive, ready and versions. -const API_VERSION = 13; +// 13 — the dashboard at /status (password), /api/health only says alive, ready and versions; +// 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 diff --git a/server/parser.js b/server/parser.js index 790b89f..fec2a94 100644 --- a/server/parser.js +++ b/server/parser.js @@ -8,8 +8,9 @@ import { join } from "node:path"; import { createHash } from "node:crypto"; import { parseWeekRanges, parseTextDates, DATE_LIST, weekday } from "../shared/weeks.js"; import { roomMoves, onlyPlace, weekPlan, partsByWeek } from "./rooms.js"; +import { placeNote, lessonPlace, placeOf } from "./places.js"; const run = promisify(execFile); -export const PARSER_VERSION = 13; +export const PARSER_VERSION = 14; const days = [ "понедельник", "вторник", @@ -66,7 +67,9 @@ function conditionIn(value) { ); 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); let rest = raw.slice(condition.length).trim(); // "Экзамен. Финансы …" 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 ("ВВЕДЕНИЕ В ВЫСШЕЕ ОБРАЗОВАНИЕ / г. Полоцк, …"): // the heading names it; the whole text stays in the details. 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 token = kind?.[1].replace(/[.\s/]/g, "").toLowerCase() || @@ -148,6 +151,7 @@ export function details(raw) { : "Занятие", condition, ...(own && { ownSubgroup: Number(own[1]) }), + ...(place && { place }), }; } // 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 = {}) { const document = readDocx(buffer); const lessons = [], + // "Занятия в Полоцке": the days a group, or one of its subgroups, spends in another town. + places = [], groups = new Set(), warnings = new Set(); // 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 = cell.merge === "restart" && 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)) { // "Единый день информирования 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"). @@ -571,20 +599,7 @@ export function parseDocx(buffer, source = {}) { data = read(alternative.slice(lead.length).replace(/^[\s–—:-]+/, "")); if (oneDate) data.condition = lead.text; } - const subgroups = 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}`, - })); + const subgroups = [...columnSubgroups]; // A subgroup named in the cell ("2п/гр.") when the column does not tell one. const own = data.ownSubgroup; delete data.ownSubgroup; @@ -677,12 +692,22 @@ export function parseDocx(buffer, source = {}) { warnings.add( "Номера подгрупп обозначают столбцы внутри группы в исходном 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 { id: createHash("sha256").update(buffer).update(source.fileId || source.name || "").digest("hex").slice(0, 20), groups: [...groups], lessons: lessons.sort( (a, b) => a.day - b.day || a.start.localeCompare(b.start), ), + ...(places.length && { places }), notes: document.notes, warnings: [...warnings], source: { diff --git a/server/places.js b/server/places.js new file mode 100644 index 0000000..6936f65 --- /dev/null +++ b/server/places.js @@ -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; +} diff --git a/server/timetable.js b/server/timetable.js index 1caff80..f028b1a 100644 --- a/server/timetable.js +++ b/server/timetable.js @@ -2,6 +2,7 @@ // third-party clients use it instead of re-implementing the white/green week rules. import { semesterView, addDays, weekday, weekIndexOf } from "../shared/weeks.js"; import { lessonKey, subgroupsOf } from "../shared/lessons.js"; +import { placesOn } from "../shared/places.js"; import { occurrence, peopleAndRooms, roomsOn, teachersOn, movedOn } from "./directory.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, rooms, 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, condition: lesson.condition, 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); 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 // 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) @@ -112,11 +116,14 @@ export function timetable({ file, schedule, calendar, group, from, days, range, if (entry.lesson.date === date || entry.lesson.dates?.includes(date)) dayLessons.push(lessonOut(entry.lesson, group, entry, date, "yes", calendar)); 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({ date, weekday: weekday(date), week: week ? { n: week.n, color: week.color } : null, lessons: dayLessons, + ...(places.length && { places }), }); } 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)) .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 { group, document: { diff --git a/shared/lessons.d.ts b/shared/lessons.d.ts index f4b39b5..77df32b 100644 --- a/shared/lessons.d.ts +++ b/shared/lessons.d.ts @@ -6,6 +6,7 @@ interface LessonLike { subject: string; teacher: string; room: string; + place?: string; type: string; condition: string; variant: "all" | "upper" | "lower"; diff --git a/shared/lessons.js b/shared/lessons.js index 63834da..7c0e4fe 100644 --- a/shared/lessons.js +++ b/shared/lessons.js @@ -27,6 +27,8 @@ const FIELDS = [ ["day", "День"], ["date", "Дата"], ["room", "Аудитория"], + // "Полоцк": the town a cell or the day's note names (parser 14). + ["place", "Город"], ["teacher", "Преподаватель"], ["condition", "Недели"], ["type", "Вид занятия"], diff --git a/shared/places.d.ts b/shared/places.d.ts new file mode 100644 index 0000000..9ffb43f --- /dev/null +++ b/shared/places.d.ts @@ -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[]; diff --git a/shared/places.js b/shared/places.js new file mode 100644 index 0000000..6f23e8e --- /dev/null +++ b/shared/places.js @@ -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); +} diff --git a/src/App.tsx b/src/App.tsx index fc94f90..7bebb98 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -60,6 +60,7 @@ import { personalFilter, subgroupsOf, } from "../shared/lessons"; +import { placesFor, placesOn, type DayPlace } from "../shared/places"; import qrcode from "qrcode-generator"; const SOURCE = "https://nextcloud.psu.by/index.php/s/no5zKbMrns6j5jQ?dir=/2026-2027"; @@ -197,10 +198,10 @@ function LessonCard({ {lesson.teacher} )} - {lesson.room && ( + {(lesson.room || lesson.place) && ( - Ауд. {lesson.room} + {whereLabel(lesson, "Ауд.")} )} {subgroup && ( @@ -248,6 +249,29 @@ function LessonCard({ ); } +/** "Ауд. 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 ( +
+ {places.map((p) => ( +

+ + + {p.text} + {p.subgroup ? `${p.subgroup} · пометка в Word на этот день` : "Пометка в Word на этот день"} + +

+ ))} +
+ ); +} function ScheduleList({ lessons, ctx, @@ -434,7 +458,7 @@ function ChangesList({ record }: { record: ChangeRecord }) { {whenLabel(l)} {l.subject} - {[l.type, l.teacher, l.room && `ауд. ${l.room}`, l.condition] + {[l.type, l.teacher, whereLabel(l, "ауд."), l.condition] .filter(Boolean) .join(" · ")} @@ -452,7 +476,7 @@ function ChangesList({ record }: { record: ChangeRecord }) { {whenLabel(l)} {l.subject} - {[l.type, l.teacher, l.room && `ауд. ${l.room}`, l.condition] + {[l.type, l.teacher, whereLabel(l, "ауд."), l.condition] .filter(Boolean) .join(" · ")} @@ -998,6 +1022,15 @@ export default function App() { return list.filter((l) => l.day === d && occursOn(l, iso) !== false); }; 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 hiddenThisWeek = DAYS.reduce( (sum, _, i) => sum + lessonsOn(hiddenLessons, i + 1).length, @@ -1638,10 +1671,13 @@ export default function App() { }} > {dayLessons.length ? ( - + <> + + + ) : (
{longDate(iso)}} + {l.subject} - {l.condition} {l.teacher} {l.room && `· ауд. ${l.room}`} + {l.condition} {l.teacher} {(l.room || l.place) && `· ${whereLabel(l, "ауд.")}`}
))} @@ -2169,6 +2206,13 @@ export default function App() { Аудитория {details.room || "Не указана"} + {details.place && ( + + + Город + {details.place} + + )} {calendar && detailDates && !details.date && hasWeekLimit(details) && (
diff --git a/src/Search.tsx b/src/Search.tsx index c53d080..de3b7f4 100644 --- a/src/Search.tsx +++ b/src/Search.tsx @@ -35,6 +35,8 @@ interface DayLesson { teachers: string[]; rooms: string[]; moved: boolean; + /** "Полоцк", when the document names the town (API 14). */ + place?: string | null; distance: boolean; groups: string[]; documents: { id: string; name: string }[]; @@ -460,10 +462,10 @@ export default function Search({ {l.groups.join(", ")} {target.kind === "teacher" ? ( - l.rooms.length > 0 && ( + (l.rooms.length > 0 || l.place) && ( - Ауд. {l.rooms.join(", ")} + {[l.rooms.length > 0 && `Ауд. ${l.rooms.join(", ")}`, l.place].filter(Boolean).join(" · ")} ) ) : ( diff --git a/src/icons.tsx b/src/icons.tsx index c8f7156..c45ba6d 100644 --- a/src/icons.tsx +++ b/src/icons.tsx @@ -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 Info from "@material-symbols/svg-400/rounded/info.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 MobileArrowDown from "@material-symbols/svg-400/rounded/mobile_arrow_down.svg?react"; import MoreHoriz from "@material-symbols/svg-400/rounded/more_horiz.svg?react"; @@ -66,6 +67,7 @@ const icons = { groups: Groups, info: Info, keyboard_arrow_down: KeyboardArrowDown, + location_city: LocationCity, location_on: LocationOn, mobile_arrow_down: MobileArrowDown, more_horiz: MoreHoriz, diff --git a/src/styles.css b/src/styles.css index 7c7355d..701a274 100644 --- a/src/styles.css +++ b/src/styles.css @@ -610,6 +610,39 @@ a { display: grid; 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] { animation: day-enter var(--md-sys-motion-duration-medium) var(--md-sys-motion-easing-emphasized-decelerate); diff --git a/src/types.ts b/src/types.ts index 8e1852e..febe93c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,5 @@ import type { WeekRange } from "../shared/weeks"; +import type { PlaceNote } from "../shared/places"; export interface Lesson { id: string; day: number; @@ -9,6 +10,8 @@ export interface Lesson { subject: string; teacher: string; room: string; + /** "Полоцк": the town, when the cell or the day's note names one (parser 14). */ + place?: string; type: string; condition: string; groups: string[]; @@ -24,6 +27,8 @@ export interface Schedule { id: string; groups: string[]; lessons: Lesson[]; + /** "Занятия в Полоцке": days of a group or subgroup in another town, not lessons. */ + places?: PlaceNote[]; notes: string[]; warnings: string[]; source: { diff --git a/test/places.test.js b/test/places.test.js new file mode 100644 index 0000000..e8b1b1a --- /dev/null +++ b/test/places.test.js @@ -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, "&").replace(/ + `${span > 1 ? `` : ""}${text + .split("\n") + .map((line) => `${escape(line)}`) + .join("")}`; +const docx = (rows) => { + const zip = new AdmZip(); + zip.addFile( + "word/document.xml", + Buffer.from( + `` + + `Расписание. Осенний семестр 2026/2027 учебного года` + + `${rows.map((r) => `${r.join("")}`).join("")}`, + ), + ); + 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)); +});