feat: read "Занятия в Полоцке" as a day's town, show it in all clients
- Parser 14 (server/places.js): "Занятия в Полоцке/Новополоцке/г. Новополоцк" over a day's first row is no longer a lesson but a note of the day for its column (`places`); lessons of that day get `place`. A lesson below the note in the same cell keeps its own subject.
- A town on its own line ("Новополоцк", "г.Новополоцк") or after a room ("246(Новополоцк)") becomes the lesson's `place` and leaves the subject and room. "История Полоцка" is not a town.
- API 14: `place` on lessons and `places` on days of /api/timetable, `place` in the directory. The day notes are shared by the server and the PWA (shared/places.js); the same note in every subgroup reads as the whole group's.
- Android, PWA and iOS: a "Занятия в Полоцке" card above the day's lessons (day pages, calendar, overview, week view, desk stand), "Полоцк" under the date in the week grid, "249Ж · Полоцк" on lesson cards, a "Город" row in details, the town in widgets, tile, reminders and change lists.
- Snapshot lines show the town and the day notes; tests for notes, towns in cells and the API.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,12 @@
|
|||||||
"runtimeExecutable": "env",
|
"runtimeExecutable": "env",
|
||||||
"runtimeArgs": ["PORT=3002", "node", "--watch", "server/index.js"],
|
"runtimeArgs": ["PORT=3002", "node", "--watch", "server/index.js"],
|
||||||
"port": 3002
|
"port": 3002
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "vite-docker",
|
||||||
|
"runtimeExecutable": "env",
|
||||||
|
"runtimeArgs": ["API_PORT=3010", "npx", "vite", "--host", "127.0.0.1", "--port", "5174"],
|
||||||
|
"port": 5174
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
12
README.md
12
README.md
@@ -29,6 +29,7 @@ Vite работает на http://127.0.0.1:5173 и проксирует `/api`
|
|||||||
- Отдельные карточки для альтернативных занятий и условий по неделям. Сохраняются исходный текст, цвет ячейки, подгруппы, даты и ограничения.
|
- Отдельные карточки для альтернативных занятий и условий по неделям. Сохраняются исходный текст, цвет ячейки, подгруппы, даты и ограничения.
|
||||||
- Учебные недели по таблице университета: переключатель недель с датами, показ только занятий выбранной недели, статус занятия («ещё 4 занятия · до 27 нояб.», «начнётся 20 нояб.») и сетка недель в деталях. Режим «Все строки Word» показывает документ как есть.
|
- Учебные недели по таблице университета: переключатель недель с датами, показ только занятий выбранной недели, статус занятия («ещё 4 занятия · до 27 нояб.», «начнётся 20 нояб.») и сетка недель в деталях. Режим «Все строки Word» показывает документ как есть.
|
||||||
- Номера пар и текущая пара («Идёт · ещё 25 мин») по расписанию звонков.
|
- Номера пар и текущая пара («Идёт · ещё 25 мин») по расписанию звонков.
|
||||||
|
- **Занятия в другом городе.** Если в Word над днём написано «Занятия в Полоцке», над парами этого дня — в виде «День» и «Неделя» — стоит карточка «Занятия в Полоцке · пометка в Word на этот день» (с подгруппой, если заметка только её). В карточке пары город стоит рядом с аудиторией («Ауд. 249Ж · Полоцк»), в деталях — строка «Город». Сохранённые раньше расписания перечитываются по версии парсера и получают города сами.
|
||||||
- «Что изменилось»: когда университет перепубликует документ, приложение сравнивает новую версию с сохранённой и показывает для вашей группы изменённые поля (аудитория, время, преподаватель, недели), добавленные и удалённые занятия. Последний список изменений открывается из меню.
|
- «Что изменилось»: когда университет перепубликует документ, приложение сравнивает новую версию с сохранённой и показывает для вашей группы изменённые поля (аудитория, время, преподаватель, недели), добавленные и удалённые занятия. Последний список изменений открывается из меню.
|
||||||
- Моя подгруппа и скрытые занятия: в «Группах» можно выбрать подгруппу — занятия другой подгруппы пропадут; любое занятие можно скрыть из его карточки и вернуть кнопкой «Скрыто · показать». Настройки хранятся только в этом браузере.
|
- Моя подгруппа и скрытые занятия: в «Группах» можно выбрать подгруппу — занятия другой подгруппы пропадут; любое занятие можно скрыть из его карточки и вернуть кнопкой «Скрыто · показать». Настройки хранятся только в этом браузере.
|
||||||
- **Поиск по университету** (вкладка «Поиск»): преподаватель или аудитория — занятия на выбранный день по всем расписаниям каталога; свободные аудитории на дату и пару. Сервер разбирает все документы каталога в фоне (первый раз около 20–30 секунд, дальше из кэша) и считает занятость с учётом недель, цвета строк, семестра и разовых переносов аудиторий вида «432 (07.10-ауд.272)». Дистанционные занятия («200 (ДО)») аудиторию не занимают. Каждый ответ сообщает, сколько документов учтено; «свободна» означает «свободна по расписаниям из Word», мероприятия и закрытые аудитории расписание не знает. Работает только онлайн.
|
- **Поиск по университету** (вкладка «Поиск»): преподаватель или аудитория — занятия на выбранный день по всем расписаниям каталога; свободные аудитории на дату и пару. Сервер разбирает все документы каталога в фоне (первый раз около 20–30 секунд, дальше из кэша) и считает занятость с учётом недель, цвета строк, семестра и разовых переносов аудиторий вида «432 (07.10-ауд.272)». Дистанционные занятия («200 (ДО)») аудиторию не занимают. Каждый ответ сообщает, сколько документов учтено; «свободна» означает «свободна по расписаниям из Word», мероприятия и закрытые аудитории расписание не знает. Работает только онлайн.
|
||||||
@@ -163,6 +164,13 @@ npm run sync
|
|||||||
|
|
||||||
**Группы с направлением в скобках** — «24‑ИЯ (РА)и‑1» … «24‑ИЯ (РА)и‑6» — разные группы. Они пишутся, как у университета, с пробелом перед скобкой. До версии парсера 13 они сливались в одну «24‑ИЯ(РА)» со всеми занятиями трёх групп. Студентам с такой сохранённой группой нужно выбрать свою группу заново. На старое имя `/api/timetable` отвечает `404 group_not_found` и в тексте ошибки называет новые группы. Имя без пробела («24‑ИЯ(РА)и‑1») по‑прежнему находит группу.
|
**Группы с направлением в скобках** — «24‑ИЯ (РА)и‑1» … «24‑ИЯ (РА)и‑6» — разные группы. Они пишутся, как у университета, с пробелом перед скобкой. До версии парсера 13 они сливались в одну «24‑ИЯ(РА)» со всеми занятиями трёх групп. Студентам с такой сохранённой группой нужно выбрать свою группу заново. На старое имя `/api/timetable` отвечает `404 group_not_found` и в тексте ошибки называет новые группы. Имя без пробела («24‑ИЯ(РА)и‑1») по‑прежнему находит группу.
|
||||||
|
|
||||||
|
**Занятия в другом городе** (`server/places.js`, парсер 14). Большинство пар — в Новополоцке, но часть факультетов учится в Полоцке, и Word отмечает это по‑разному:
|
||||||
|
- **«Занятия в Полоцке» над первой парой дня** (24‑ИЯ (РА)и‑1…6, 25‑ИЯ (РА)и‑3, 24‑РГФи‑1, 22‑ЛОМК, 25‑ТДи) — не занятие, а заметка дня для столбца: в этот день все пары группы или подгруппы идут в том городе (и аудитории у них полоцкие — Г, Д, Е, Ж). Заметка лежит в разобранном документе в `places` (`{day, date, groups, subgroups, place, text}`), занятиям этого дня проставляется `place`. Если в той же ячейке под заметкой есть пара, это обычное занятие без приставки в названии. Занятие всей группы получает город, когда заметка стоит во всех её подгруппах.
|
||||||
|
- **Город строкой в ячейке занятия** — «Новополоцк», «г.Новополоцк» — или **после аудитории** — «246(Новополоцк)», «121(г.Новополоцк)» — город этого занятия; из названия и аудитории он убирается.
|
||||||
|
- Пишется и «Занятия в г. Новополоцк», «Занятия проходят в …», «Занятия в Междуречье». Город приводится к именительному падежу («Полоцке» → «Полоцк»). Название предмета вроде «История Полоцка» городом не считается.
|
||||||
|
- В документах «Введение в высшее образование» строка «г. Полоцк, Дом офицеров» даёт занятию город, а место проведения остаётся в тексте.
|
||||||
|
- Какие заметки показать над днём группы, решает общий модуль `shared/places.js`: им пользуются и `/api/timetable`, и PWA. Одинаковая заметка во всех подгруппах сводится к одной для всей группы, а заметка своей подгруппы показывается как заметка всего дня.
|
||||||
|
|
||||||
Для старых `.doc` на сервере нужен LibreOffice Writer (`soffice` в PATH или `LIBREOFFICE_BIN`). Конвертация может изменить сложное форматирование, результат нужно сверить. Реальные документы выбранного источника — `.docx`; поддержка `.doc` проверяется отдельно на образце, преобразованном LibreOffice.
|
Для старых `.doc` на сервере нужен LibreOffice Writer (`soffice` в PATH или `LIBREOFFICE_BIN`). Конвертация может изменить сложное форматирование, результат нужно сверить. Реальные документы выбранного источника — `.docx`; поддержка `.doc` проверяется отдельно на образце, преобразованном LibreOffice.
|
||||||
|
|
||||||
PDF, Excel, изображения и сканы пока не импортируются. Они не выдаются за распознанное расписание.
|
PDF, Excel, изображения и сканы пока не импортируются. Они не выдаются за распознанное расписание.
|
||||||
@@ -241,6 +249,7 @@ DOMAIN=schedule.example.by docker compose --profile https up -d --build
|
|||||||
- Планы: задания и заметки по дням — к занятию или общие, без привязки к паре, на выбранный день, просроченные сверху, «+» прикрепляет задание к ближайшему занятию. Три макета, как в School Planner: «Повестка дня» (все дни с липкими заголовками и полосой недели, которая следует за списком), «Список» (только дни с делами) и «Доска» (колонки дней). В настройках вида — пары дня и выполненные задания.
|
- Планы: задания и заметки по дням — к занятию или общие, без привязки к паре, на выбранный день, просроченные сверху, «+» прикрепляет задание к ближайшему занятию. Три макета, как в School Planner: «Повестка дня» (все дни с липкими заголовками и полосой недели, которая следует за списком), «Список» (только дни с делами) и «Доска» (колонки дней). В настройках вида — пары дня и выполненные задания.
|
||||||
- Календарь: месяц с отметками дней с парами и с планами, зелёные недели подсвечены; под ним лист с планами и парами выбранного дня. Лист можно поднять — месяц сворачивается до выбранной недели.
|
- Календарь: месяц с отметками дней с парами и с планами, зелёные недели подсвечены; под ним лист с планами и парами выбранного дня. Лист можно поднять — месяц сворачивается до выбранной недели.
|
||||||
- Обратный отсчёт до сессии в «Обзоре»: дни до ближайшего экзамена или зачёта из `/api/exams` (консультации — только в списке), по нажатию — вся сессия. Пока университет не опубликовал сессию, отсчёт идёт до конца семестра. Экзамены кэшируются на устройстве.
|
- Обратный отсчёт до сессии в «Обзоре»: дни до ближайшего экзамена или зачёта из `/api/exams` (консультации — только в списке), по нажатию — вся сессия. Пока университет не опубликовал сессию, отсчёт идёт до конца семестра. Экзамены кэшируются на устройстве.
|
||||||
|
- **Занятия в другом городе.** Если в Word над днём написано «Занятия в Полоцке», над парами дня появляется карточка «Занятия в Полоцке · пометка в Word на этот день» — на странице дня, в «Календаре», в «Обзоре» (сегодня и ближайший день) и на подставке «ноутбука», а в недельной сетке под датой — метка «Полоцк». Если заметка относится к одной подгруппе, а выбраны все, в карточке указана подгруппа. В карточках пар город стоит рядом с аудиторией («249Ж · Полоцк»), в подробностях — строка «Город»; так же — в виджетах, плитке и напоминаниях. Раньше такая заметка показывалась отдельной парой «Занятия в Полоцке».
|
||||||
- **Группа, которой больше нет.** Если сервер отвечает `group_not_found` или `document_not_found` — группу разделили («24‑ИЯ(РА)» стала «24‑ИЯ (РА)и‑1»…), или документ ушёл из каталога, — плашка с текстом сервера предлагает «Выбрать группу». Сохранённая копия остаётся на экране, а устаревшая группа убирается из недавних.
|
- **Группа, которой больше нет.** Если сервер отвечает `group_not_found` или `document_not_found` — группу разделили («24‑ИЯ(РА)» стала «24‑ИЯ (РА)и‑1»…), или документ ушёл из каталога, — плашка с текстом сервера предлагает «Выбрать группу». Сохранённая копия остаётся на экране, а устаревшая группа убирается из недавних.
|
||||||
- **Изменения в расписании.** Как в PWA: когда университет перезаливает документ группы, приложение сравнивает новое расписание с копией на устройстве. В «Обзоре» появляется карточка «Расписание обновлено · N изменений» с кнопкой «Показать», а в списке видно, что изменилось, что добавлено и что удалено, например «Аудитория: 374 → 375». Карточка показывается до просмотра или закрытия, последний список остаётся в настройках («Последние изменения»). Сравнение срабатывает и при фоновом обновлении (`SyncWorker`, раз в 6 часов). Если изменения нашлись в фоне, приходит уведомление «Расписание 23-АРХ изменилось» с первыми изменениями, например «Проектирование общественных зданий: аудитория 374 → 375». Уведомление локальное, сервер push для него не нужен. Его можно выключить в настройках («Изменения в расписании», по умолчанию включено), а на Android 13+ нужно разрешение на уведомления. Считается только новая версия того же файла: смена версии парсера или переход на другой документ изменениями не считаются. Сравниваются строки Word по дням недели и разовые занятия со своей датой, а не каждая рассчитанная дата.
|
- **Изменения в расписании.** Как в PWA: когда университет перезаливает документ группы, приложение сравнивает новое расписание с копией на устройстве. В «Обзоре» появляется карточка «Расписание обновлено · N изменений» с кнопкой «Показать», а в списке видно, что изменилось, что добавлено и что удалено, например «Аудитория: 374 → 375». Карточка показывается до просмотра или закрытия, последний список остаётся в настройках («Последние изменения»). Сравнение срабатывает и при фоновом обновлении (`SyncWorker`, раз в 6 часов). Если изменения нашлись в фоне, приходит уведомление «Расписание 23-АРХ изменилось» с первыми изменениями, например «Проектирование общественных зданий: аудитория 374 → 375». Уведомление локальное, сервер push для него не нужен. Его можно выключить в настройках («Изменения в расписании», по умолчанию включено), а на Android 13+ нужно разрешение на уведомления. Считается только новая версия того же файла: смена версии парсера или переход на другой документ изменениями не считаются. Сравниваются строки Word по дням недели и разовые занятия со своей датой, а не каждая рассчитанная дата.
|
||||||
- Задания и заметки к паре: из карточки занятия — к этой паре или к следующей паре того же предмета. Задания отмечаются выполненными, просроченные подсвечиваются; список — в «Обзоре», по предмету — в «Предметах», точка на блоке в недельной сетке. Хранятся только на устройстве (`notes.json`) и привязаны к группе.
|
- Задания и заметки к паре: из карточки занятия — к этой паре или к следующей паре того же предмета. Задания отмечаются выполненными, просроченные подсвечиваются; список — в «Обзоре», по предмету — в «Предметах», точка на блоке в недельной сетке. Хранятся только на устройстве (`notes.json`) и привязаны к группе.
|
||||||
@@ -259,7 +268,7 @@ DOMAIN=schedule.example.by docker compose --profile https up -d --build
|
|||||||
- **В «Планах»** они видны всегда, даже с выключенными «Парами дня».
|
- **В «Планах»** они видны всегда, даже с выключенными «Парами дня».
|
||||||
- **В календаре** день с экзаменом или зачётом отмечен янтарным квадратом, а точка планов стала фиолетовой.
|
- **В календаре** день с экзаменом или зачётом отмечен янтарным квадратом, а точка планов стала фиолетовой.
|
||||||
|
|
||||||
Даты считает сервер: `GET /api/timetable?doc=<id>&group=<группа>&from=ГГГГ-ММ-ДД&days=N` (до 62 дней) или с `&range=semester` — весь текущий семестр от первого до последнего дня; приложение хранит его целиком и работает с ним без сети. Ответ содержит дни с неделями, номерами пар и пометкой `uncertain` для нераспознанных пометок, плюс строки Word (`weekly`) и подгруппы. Поэтому правила белых и зелёных недель в Kotlin не дублируются.
|
Даты считает сервер: `GET /api/timetable?doc=<id>&group=<группа>&from=ГГГГ-ММ-ДД&days=N` (до 62 дней) или с `&range=semester` — весь текущий семестр от первого до последнего дня; приложение хранит его целиком и работает с ним без сети. Ответ содержит дни с неделями, номерами пар и пометкой `uncertain` для нераспознанных пометок, плюс строки Word (`weekly`) и подгруппы. С API 14 у занятия есть `place` — город, если документ его называет, а у дня с парами — `places`: заметки «Занятия в Полоцке» (`{place, text, subgroup}`, `subgroup: null` — для всей группы; одинаковая заметка во всех подгруппах сводится к одной). Поэтому правила белых и зелёных недель в Kotlin не дублируются.
|
||||||
|
|
||||||
Сборка (JDK 17, Android SDK 37):
|
Сборка (JDK 17, Android SDK 37):
|
||||||
|
|
||||||
@@ -276,6 +285,7 @@ cd android
|
|||||||
|
|
||||||
- **Онбординг:** приветствие (три стеклянные «капли» сливаются в одну), затем группа, подгруппа (только если группа делится на подгруппы) и напоминания. Разрешение на уведомления спрашивается на последнем шаге.
|
- **Онбординг:** приветствие (три стеклянные «капли» сливаются в одну), затем группа, подгруппа (только если группа делится на подгруппы) и напоминания. Разрешение на уведомления спрашивается на последнем шаге.
|
||||||
- **Стеклянная панель вкладок:** три раздела — «Обзор», «Расписание», «Планы» — и поиск отдельной круглой кнопкой справа (роль `.search`). Нажатие превращает кнопку в поле ввода над клавиатурой, как в приложениях Apple (`tabViewSearchActivation(.searchTabSelection)`). «Календарь» открывается кнопкой в «Планах», «Предметы» — карточкой в «Обзоре». На iPad панель превращается в боковое меню. Группа и настройки открываются из панели «Обзора»; тема меняется сразу во всех окнах, включая открытые настройки.
|
- **Стеклянная панель вкладок:** три раздела — «Обзор», «Расписание», «Планы» — и поиск отдельной круглой кнопкой справа (роль `.search`). Нажатие превращает кнопку в поле ввода над клавиатурой, как в приложениях Apple (`tabViewSearchActivation(.searchTabSelection)`). «Календарь» открывается кнопкой в «Планах», «Предметы» — карточкой в «Обзоре». На iPad панель превращается в боковое меню. Группа и настройки открываются из панели «Обзора»; тема меняется сразу во всех окнах, включая открытые настройки.
|
||||||
|
- **Занятия в другом городе:** как в Android и PWA. Над парами дня, в «Календаре» и в «Обзоре» (сегодня и ближайший день) стоит карточка «Занятия в Полоцке», в недельной сетке под датой — метка «Полоцк». В карточках пар город стоит рядом с аудиторией, в подробностях — строка «Город»; так же — в виджетах и напоминаниях.
|
||||||
- **Обзор:** неделя (сколько пар осталось, чип на каждый день), обратный отсчёт до сессии или конца семестра, следующие занятия с отсчётом «Через 20 мин», посещаемость одним касанием, задания и ближайший учебный день. На iPad рядом показывается недельная сетка.
|
- **Обзор:** неделя (сколько пар осталось, чип на каждый день), обратный отсчёт до сессии или конца семестра, следующие занятия с отсчётом «Через 20 мин», посещаемость одним касанием, задания и ближайший учебный день. На iPad рядом показывается недельная сетка.
|
||||||
- **Изменения в расписании:** как в Android и PWA — карточка «Расписание обновлено · N изменений» в «Обзоре» после перезаливки документа, по «Показать» — лист с изменёнными, добавленными и удалёнными занятиями; последний список — в настройках. Сравнение срабатывает и при фоновом обновлении `BGAppRefreshTask`: тогда приходит локальное уведомление (настройка «Изменения в расписании»). Когда запускать фоновое обновление, решает iOS, поэтому уведомление может прийти через несколько часов, а если приложение смахнули из переключателя — только после следующего открытия.
|
- **Изменения в расписании:** как в Android и PWA — карточка «Расписание обновлено · N изменений» в «Обзоре» после перезаливки документа, по «Показать» — лист с изменёнными, добавленными и удалёнными занятиями; последний список — в настройках. Сравнение срабатывает и при фоновом обновлении `BGAppRefreshTask`: тогда приходит локальное уведомление (настройка «Изменения в расписании»). Когда запускать фоновое обновление, решает iOS, поэтому уведомление может прийти через несколько часов, а если приложение смахнули из переключателя — только после следующего открытия.
|
||||||
- **Расписание:** дни листаются в сторону под плавающей стеклянной лентой недели. На зелёных неделях стекло окрашено зелёным. Есть недельная сетка по часам и строки Word, если семестр не найден. В подробностях пары: время по звонкам, посещаемость, задания, исходная запись Word и скрытие таких занятий.
|
- **Расписание:** дни листаются в сторону под плавающей стеклянной лентой недели. На зелёных неделях стекло окрашено зелёным. Есть недельная сетка по часам и строки Word, если семестр не найден. В подробностях пары: время по звонкам, посещаемость, задания, исходная запись Word и скрытие таких занятий.
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ private fun fields(a: ChangedLesson, b: ChangedLesson): List<FieldChange> {
|
|||||||
("Вид занятия" to (a.lesson.type to b.lesson.type)),
|
("Вид занятия" to (a.lesson.type to b.lesson.type)),
|
||||||
("Преподаватель" to (a.lesson.teachers.joinToString(", ") to b.lesson.teachers.joinToString(", "))),
|
("Преподаватель" to (a.lesson.teachers.joinToString(", ") to b.lesson.teachers.joinToString(", "))),
|
||||||
("Аудитория" to (a.lesson.rooms.joinToString(", ") to b.lesson.rooms.joinToString(", "))),
|
("Аудитория" to (a.lesson.rooms.joinToString(", ") to b.lesson.rooms.joinToString(", "))),
|
||||||
|
("Город" to (a.lesson.place.orEmpty() to b.lesson.place.orEmpty())),
|
||||||
).filter { (_, values) -> values.first.trim() != values.second.trim() }
|
).filter { (_, values) -> values.first.trim() != values.second.trim() }
|
||||||
.map { (label, values) -> FieldChange(label, shown(values.first), shown(values.second)) }
|
.map { (label, values) -> FieldChange(label, shown(values.first), shown(values.second)) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,10 +40,29 @@ data class Week(val n: Int, val color: String) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class Day(val date: String, val weekday: Int, val week: Week? = null, val lessons: List<Lesson> = emptyList())
|
data class Day(
|
||||||
|
val date: String,
|
||||||
|
val weekday: Int,
|
||||||
|
val week: Week? = null,
|
||||||
|
val lessons: List<Lesson> = emptyList(),
|
||||||
|
val places: List<DayPlace> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class WeeklyDay(val day: Int, val lessons: List<Lesson> = emptyList())
|
data class WeeklyDay(val day: Int, val lessons: List<Lesson> = emptyList(), val places: List<DayPlace> = emptyList())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Занятия в Полоцке" over a day in Word: that day the group's lessons (or one subgroup's,
|
||||||
|
* when `subgroup` is set) are in another town (server/timetable.js placesOn).
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class DayPlace(val place: String, val text: String, val subgroup: String? = null)
|
||||||
|
|
||||||
|
/** The notes for the student's subgroup; a note for their own subgroup reads as the whole day's. */
|
||||||
|
fun List<DayPlace>.forSubgroup(subgroup: String?): List<DayPlace> =
|
||||||
|
filter { subgroup == null || it.subgroup == null || it.subgroup == subgroup }
|
||||||
|
.map { if (subgroup != null) it.copy(subgroup = null) else it }
|
||||||
|
.distinct()
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class Lesson(
|
data class Lesson(
|
||||||
@@ -56,6 +75,8 @@ data class Lesson(
|
|||||||
val teachers: List<String> = emptyList(),
|
val teachers: List<String> = emptyList(),
|
||||||
val rooms: List<String> = emptyList(),
|
val rooms: List<String> = emptyList(),
|
||||||
val roomMoved: Boolean = false,
|
val roomMoved: Boolean = false,
|
||||||
|
/** The town, when the cell or the day's note names one: "Полоцк". */
|
||||||
|
val place: String? = null,
|
||||||
val distance: Boolean = false,
|
val distance: Boolean = false,
|
||||||
val condition: String = "",
|
val condition: String = "",
|
||||||
val variant: String = "all",
|
val variant: String = "all",
|
||||||
@@ -68,6 +89,10 @@ data class Lesson(
|
|||||||
/** A recurring lesson the student may hide: the subject with its kind, in every week. */
|
/** A recurring lesson the student may hide: the subject with its kind, in every week. */
|
||||||
val Lesson.hideKey: String get() = subject.trim().lowercase() + "|" + type.trim().lowercase()
|
val Lesson.hideKey: String get() = subject.trim().lowercase() + "|" + type.trim().lowercase()
|
||||||
|
|
||||||
|
/** A room label with the lesson's town when the document names one: "ауд. 203Г · Полоцк", or the town alone. */
|
||||||
|
fun withPlace(room: String?, place: String?): String? =
|
||||||
|
listOfNotNull(room?.takeIf { it.isNotEmpty() }, place).joinToString(" · ").ifEmpty { null }
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class GroupsResponse(
|
data class GroupsResponse(
|
||||||
val results: List<GroupResult> = emptyList(),
|
val results: List<GroupResult> = emptyList(),
|
||||||
@@ -118,6 +143,7 @@ data class DirectoryLesson(
|
|||||||
val teachers: List<String> = emptyList(),
|
val teachers: List<String> = emptyList(),
|
||||||
val rooms: List<String> = emptyList(),
|
val rooms: List<String> = emptyList(),
|
||||||
val moved: Boolean = false,
|
val moved: Boolean = false,
|
||||||
|
val place: String? = null,
|
||||||
val distance: Boolean = false,
|
val distance: Boolean = false,
|
||||||
val groups: List<String> = emptyList(),
|
val groups: List<String> = emptyList(),
|
||||||
val condition: String = "",
|
val condition: String = "",
|
||||||
|
|||||||
@@ -114,4 +114,14 @@ class Repository(context: Context, val api: Api, private val settings: Settings)
|
|||||||
|
|
||||||
fun dayOf(timetable: Timetable, date: LocalDate): Day? =
|
fun dayOf(timetable: Timetable, date: LocalDate): Day? =
|
||||||
timetable.days.firstOrNull { it.date == date.toString() }
|
timetable.days.firstOrNull { it.date == date.toString() }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Занятия в Полоцке" on the date for the student's subgroup, and only on a day they have
|
||||||
|
* lessons. A note for their own subgroup reads as the whole day's.
|
||||||
|
*/
|
||||||
|
fun placesOn(timetable: Timetable, date: LocalDate, subgroup: String?, hidden: Set<String> = emptySet()): List<DayPlace> {
|
||||||
|
val day = dayOf(timetable, date) ?: return emptyList()
|
||||||
|
if (day.places.isEmpty() || lessonsOn(timetable, date, subgroup, hidden).isEmpty()) return emptyList()
|
||||||
|
return day.places.forSubgroup(subgroup)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package org.xtimms.psu.schedule.notify
|
package org.xtimms.psu.schedule.notify
|
||||||
|
|
||||||
import org.xtimms.psu.schedule.data.hideKey
|
import org.xtimms.psu.schedule.data.hideKey
|
||||||
|
import org.xtimms.psu.schedule.data.withPlace
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.app.AlarmManager
|
import android.app.AlarmManager
|
||||||
import android.app.NotificationChannel
|
import android.app.NotificationChannel
|
||||||
@@ -85,11 +86,14 @@ object Reminders {
|
|||||||
if (at.isBefore(now) || begins.isAfter(horizon) || slot >= MAX) continue
|
if (at.isBefore(now) || begins.isAfter(horizon) || slot >= MAX) continue
|
||||||
val first = lessons.first()
|
val first = lessons.first()
|
||||||
val title = if (lessons.size == 1) first.subject else lessons.joinToString(" / ") { it.subject }
|
val title = if (lessons.size == 1) first.subject else lessons.joinToString(" / ") { it.subject }
|
||||||
val place = lessons.flatMap { it.rooms }.distinct().joinToString(", ")
|
val place = withPlace(
|
||||||
|
lessons.flatMap { it.rooms }.distinct().joinToString(", ").takeIf { it.isNotEmpty() }?.let { "ауд. $it" },
|
||||||
|
lessons.firstNotNullOfOrNull { it.place },
|
||||||
|
)
|
||||||
val text = buildString {
|
val text = buildString {
|
||||||
append(start)
|
append(start)
|
||||||
first.pair?.let { append(" · $it пара") }
|
first.pair?.let { append(" · $it пара") }
|
||||||
if (place.isNotEmpty()) append(" · ауд. $place")
|
place?.let { append(" · $it") }
|
||||||
if (lessons.any { it.uncertain }) append(" · сверьте недели")
|
if (lessons.any { it.uncertain }) append(" · сверьте недели")
|
||||||
}
|
}
|
||||||
val pending = intent(context, slot) {
|
val pending = intent(context, slot) {
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ class LessonTileService : TileService() {
|
|||||||
tile.state = Tile.STATE_INACTIVE
|
tile.state = Tile.STATE_INACTIVE
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) tile.subtitle = "на ближайшую неделю"
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) tile.subtitle = "на ближайшую неделю"
|
||||||
} else {
|
} else {
|
||||||
val room = next.rooms.takeIf { it.isNotEmpty() }?.let { "ауд. $it" }
|
val room = next.where
|
||||||
tile.label = listOfNotNull(next.whenLabel(), room).joinToString(" · ")
|
tile.label = listOfNotNull(next.whenLabel(), room).joinToString(" · ")
|
||||||
tile.state = if (next.running) Tile.STATE_ACTIVE else Tile.STATE_INACTIVE
|
tile.state = if (next.running) Tile.STATE_ACTIVE else Tile.STATE_INACTIVE
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) tile.subtitle = next.subject
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) tile.subtitle = next.subject
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ import org.xtimms.psu.schedule.data.longLabel
|
|||||||
import org.xtimms.psu.schedule.data.monday
|
import org.xtimms.psu.schedule.data.monday
|
||||||
import org.xtimms.psu.schedule.data.today
|
import org.xtimms.psu.schedule.data.today
|
||||||
import org.xtimms.psu.schedule.data.weekdayName
|
import org.xtimms.psu.schedule.data.weekdayName
|
||||||
|
import org.xtimms.psu.schedule.data.withPlace
|
||||||
import org.xtimms.psu.schedule.ui.theme.page
|
import org.xtimms.psu.schedule.ui.theme.page
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
import java.time.YearMonth
|
import java.time.YearMonth
|
||||||
@@ -468,6 +469,7 @@ private fun DayAgendaContent(
|
|||||||
val plans = notes.filter { it.date == date.toString() }.sortedWith(compareBy({ it.task && it.done }, { it.start }))
|
val plans = notes.filter { it.date == date.toString() }.sortedWith(compareBy({ it.task && it.done }, { it.start }))
|
||||||
val known = t?.days?.any { it.date == date.toString() } == true
|
val known = t?.days?.any { it.date == date.toString() } == true
|
||||||
val slots = if (t != null && known) vm.slotsOn(t, date, subgroup) else emptyList()
|
val slots = if (t != null && known) vm.slotsOn(t, date, subgroup) else emptyList()
|
||||||
|
val places = if (t != null && known) vm.placesOn(t, date, subgroup) else emptyList()
|
||||||
val lessons = slots.flatMap { slot -> slot.lessons.map { slot to it } }
|
val lessons = slots.flatMap { slot -> slot.lessons.map { slot to it } }
|
||||||
|
|
||||||
if (plans.isEmpty() && lessons.isEmpty()) {
|
if (plans.isEmpty() && lessons.isEmpty()) {
|
||||||
@@ -519,6 +521,7 @@ private fun DayAgendaContent(
|
|||||||
item(key = "lessons") { AnimatedItem {
|
item(key = "lessons") { AnimatedItem {
|
||||||
Section(number, "Ваши занятия", trailing = if (number == 1) date.format(DATE) else null, gap = number > 1)
|
Section(number, "Ваши занятия", trailing = if (number == 1) date.format(DATE) else null, gap = number > 1)
|
||||||
} }
|
} }
|
||||||
|
if (places.isNotEmpty()) item(key = "places") { AnimatedItem { DayPlaces(places, Modifier.padding(top = 8.dp, bottom = 12.dp)) } }
|
||||||
lessons.forEachIndexed { i, (slot, l) ->
|
lessons.forEachIndexed { i, (slot, l) ->
|
||||||
item(key = "${slot.start}|${l.key}") {
|
item(key = "${slot.start}|${l.key}") {
|
||||||
val color = subjectColor(l.subject)
|
val color = subjectColor(l.subject)
|
||||||
@@ -532,7 +535,7 @@ private fun DayAgendaContent(
|
|||||||
overColor = if (session != null) sessionColors().accent else null,
|
overColor = if (session != null) sessionColors().accent else null,
|
||||||
title = l.subject,
|
title = l.subject,
|
||||||
under = listOfNotNull(
|
under = listOfNotNull(
|
||||||
if (l.distance) "дистанционно" else l.rooms.joinToString(", ").ifEmpty { null }?.let { "ауд. $it" },
|
if (l.distance) "дистанционно" else withPlace(l.rooms.joinToString(", ").ifEmpty { null }?.let { "ауд. $it" }, l.place),
|
||||||
l.type.ifEmpty { null }?.takeIf { session == null }?.lowercase(),
|
l.type.ifEmpty { null }?.takeIf { session == null }?.lowercase(),
|
||||||
).joinToString(" · ").ifEmpty { null },
|
).joinToString(" · ").ifEmpty { null },
|
||||||
last = i == lessons.lastIndex,
|
last = i == lessons.lastIndex,
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import org.xtimms.psu.schedule.data.longLabel
|
|||||||
import org.xtimms.psu.schedule.data.parseDate
|
import org.xtimms.psu.schedule.data.parseDate
|
||||||
import org.xtimms.psu.schedule.data.plural
|
import org.xtimms.psu.schedule.data.plural
|
||||||
import org.xtimms.psu.schedule.data.shortWeekday
|
import org.xtimms.psu.schedule.data.shortWeekday
|
||||||
|
import org.xtimms.psu.schedule.data.withPlace
|
||||||
import org.xtimms.psu.schedule.ui.theme.card
|
import org.xtimms.psu.schedule.ui.theme.card
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.time.ZonedDateTime
|
import java.time.ZonedDateTime
|
||||||
@@ -106,7 +107,7 @@ fun ChangesSheet(record: ScheduleChanges) {
|
|||||||
if (record.added.isNotEmpty()) {
|
if (record.added.isNotEmpty()) {
|
||||||
Heading("Добавлено · ${record.added.size}", modifier = Modifier.padding(top = 8.dp))
|
Heading("Добавлено · ${record.added.size}", modifier = Modifier.padding(top = 8.dp))
|
||||||
record.added.forEach { row ->
|
record.added.forEach { row ->
|
||||||
ChangeItem(row, details = listOf(row.lesson.type, row.lesson.teachers.joinToString(", "), row.lesson.rooms.joinToString(", ") { "ауд. $it" }, row.lesson.condition))
|
ChangeItem(row, details = listOf(row.lesson.type, row.lesson.teachers.joinToString(", "), withPlace(row.lesson.rooms.joinToString(", ") { "ауд. $it" }, row.lesson.place).orEmpty(), row.lesson.condition))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (record.removed.isNotEmpty()) {
|
if (record.removed.isNotEmpty()) {
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ import androidx.compose.ui.unit.Dp
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import org.xtimms.psu.schedule.data.DayPlace
|
||||||
import org.xtimms.psu.schedule.data.Lesson
|
import org.xtimms.psu.schedule.data.Lesson
|
||||||
import org.xtimms.psu.schedule.data.Timetable
|
import org.xtimms.psu.schedule.data.Timetable
|
||||||
import org.xtimms.psu.schedule.data.longLabel
|
import org.xtimms.psu.schedule.data.longLabel
|
||||||
@@ -97,6 +98,7 @@ import org.xtimms.psu.schedule.data.plural
|
|||||||
import org.xtimms.psu.schedule.data.today
|
import org.xtimms.psu.schedule.data.today
|
||||||
import org.xtimms.psu.schedule.data.weekSpan
|
import org.xtimms.psu.schedule.data.weekSpan
|
||||||
import org.xtimms.psu.schedule.data.weekdayName
|
import org.xtimms.psu.schedule.data.weekdayName
|
||||||
|
import org.xtimms.psu.schedule.data.withPlace
|
||||||
import java.time.Duration
|
import java.time.Duration
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
import java.time.LocalTime
|
import java.time.LocalTime
|
||||||
@@ -226,7 +228,7 @@ fun HomeScreen(vm: MainViewModel, nav: AppNav) {
|
|||||||
item(key = "week") { AnimatedItem { WeekReport(vm, t, subgroup, date, clock) } }
|
item(key = "week") { AnimatedItem { WeekReport(vm, t, subgroup, date, clock) } }
|
||||||
item(key = "session") { AnimatedItem { SessionCard(vm, t, onOpen = { session = true }) } }
|
item(key = "session") { AnimatedItem { SessionCard(vm, t, onOpen = { session = true }) } }
|
||||||
item(key = "today-title") { AnimatedItem { DayTitle("Сегодня", date) } }
|
item(key = "today-title") { AnimatedItem { DayTitle("Сегодня", date) } }
|
||||||
item(key = "today") { AnimatedItem { TodayCard(todaySlots, clock, onOpen = { details = it to date }, onDay = { nav.openDay(date) }) } }
|
item(key = "today") { AnimatedItem { TodayCard(todaySlots, vm.placesOn(t, date, subgroup), clock, onOpen = { details = it to date }, onDay = { nav.openDay(date) }) } }
|
||||||
item(key = "attendance") { AnimatedItem { AttendanceCard(vm, todaySlots) } }
|
item(key = "attendance") { AnimatedItem { AttendanceCard(vm, todaySlots) } }
|
||||||
item(key = "tasks") { AnimatedItem { TasksCard(vm, onEdit = { editing = it }, onAll = nav.openPlans) } }
|
item(key = "tasks") { AnimatedItem { TasksCard(vm, onEdit = { editing = it }, onAll = nav.openPlans) } }
|
||||||
if (next != null) {
|
if (next != null) {
|
||||||
@@ -234,6 +236,7 @@ fun HomeScreen(vm: MainViewModel, nav: AppNav) {
|
|||||||
item(key = "next-title") { AnimatedItem { DayTitle(if (day == date.plusDays(1)) "Завтра" else day.weekdayName(), day) } }
|
item(key = "next-title") { AnimatedItem { DayTitle(if (day == date.plusDays(1)) "Завтра" else day.weekdayName(), day) } }
|
||||||
item(key = "next") {
|
item(key = "next") {
|
||||||
SectionCard(Icons.Rounded.EventAvailable, plural(slots.size, "пара", "пары", "пар").let { "${slots.size} $it" }) {
|
SectionCard(Icons.Rounded.EventAvailable, plural(slots.size, "пара", "пары", "пар").let { "${slots.size} $it" }) {
|
||||||
|
DayPlaces(vm.placesOn(t, day, subgroup))
|
||||||
slots.forEach { slot -> slot.lessons.forEach { CompactLesson(it, slot, onClick = { details = it to day }) } }
|
slots.forEach { slot -> slot.lessons.forEach { CompactLesson(it, slot, onClick = { details = it to day }) } }
|
||||||
DayButton { nav.openDay(day) }
|
DayButton { nav.openDay(day) }
|
||||||
}
|
}
|
||||||
@@ -444,7 +447,7 @@ private fun DayTitle(label: String, date: LocalDate) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun TodayCard(slots: List<Slot>, clock: LocalTime, onOpen: (Lesson) -> Unit, onDay: () -> Unit) {
|
private fun TodayCard(slots: List<Slot>, places: List<DayPlace>, clock: LocalTime, onOpen: (Lesson) -> Unit, onDay: () -> Unit) {
|
||||||
val left = slots.filter { it.end.isAfter(clock) }
|
val left = slots.filter { it.end.isAfter(clock) }
|
||||||
SectionCard(Icons.Rounded.Schedule, "Следующие занятия") {
|
SectionCard(Icons.Rounded.Schedule, "Следующие занятия") {
|
||||||
when {
|
when {
|
||||||
@@ -454,6 +457,7 @@ private fun TodayCard(slots: List<Slot>, clock: LocalTime, onOpen: (Lesson) -> U
|
|||||||
"Сегодня было ${slots.size} ${plural(slots.size, "пара", "пары", "пар")}.",
|
"Сегодня было ${slots.size} ${plural(slots.size, "пара", "пары", "пар")}.",
|
||||||
)
|
)
|
||||||
else -> {
|
else -> {
|
||||||
|
DayPlaces(places)
|
||||||
val first = left.first()
|
val first = left.first()
|
||||||
first.lessons.forEach { FeaturedLesson(it, first, clock, onClick = { onOpen(it) }) }
|
first.lessons.forEach { FeaturedLesson(it, first, clock, onClick = { onOpen(it) }) }
|
||||||
if (left.size > 1) HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
|
if (left.size > 1) HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
|
||||||
@@ -541,7 +545,7 @@ private fun FeaturedLesson(lesson: Lesson, slot: Slot, clock: LocalTime, onClick
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
val show = LocalDisplay.current
|
val show = LocalDisplay.current
|
||||||
val room = if (lesson.distance) "Дистанционно" else lesson.rooms.joinToString(", ").takeIf { show.rooms && it.isNotEmpty() }?.let { "Ауд. $it" }
|
val room = if (lesson.distance) "Дистанционно" else withPlace(lesson.rooms.joinToString(", ").takeIf { show.rooms && it.isNotEmpty() }?.let { "Ауд. $it" }, lesson.place)
|
||||||
room?.let { HomeMeta(Icons.Rounded.LocationOn, it) }
|
room?.let { HomeMeta(Icons.Rounded.LocationOn, it) }
|
||||||
if (show.teachers && lesson.teachers.isNotEmpty()) HomeMeta(Icons.Rounded.Person, lesson.teachers.joinToString(", "))
|
if (show.teachers && lesson.teachers.isNotEmpty()) HomeMeta(Icons.Rounded.Person, lesson.teachers.joinToString(", "))
|
||||||
}
|
}
|
||||||
@@ -570,7 +574,7 @@ fun CompactLesson(lesson: Lesson, slot: Slot, onClick: (() -> Unit)?) {
|
|||||||
listOfNotNull(
|
listOfNotNull(
|
||||||
session?.label,
|
session?.label,
|
||||||
slot.timeLabel(),
|
slot.timeLabel(),
|
||||||
if (lesson.distance) "дистанционно" else lesson.rooms.joinToString(", ").takeIf { show.rooms && it.isNotEmpty() }?.let { "ауд. $it" },
|
if (lesson.distance) "дистанционно" else withPlace(lesson.rooms.joinToString(", ").takeIf { show.rooms && it.isNotEmpty() }?.let { "ауд. $it" }, lesson.place),
|
||||||
lesson.type.takeIf { show.tags && it.isNotEmpty() && session == null }?.lowercase(),
|
lesson.type.takeIf { show.tags && it.isNotEmpty() && session == null }?.lowercase(),
|
||||||
).joinToString(" · "),
|
).joinToString(" · "),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package org.xtimms.psu.schedule.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.rounded.LocationCity
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import org.xtimms.psu.schedule.data.DayPlace
|
||||||
|
import org.xtimms.psu.schedule.data.Timetable
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
// "Занятия в Полоцке": Word marks the days a group spends in another town over the day's first
|
||||||
|
// row. The server reads it as a note of the day, not a lesson, and gives each lesson its town.
|
||||||
|
|
||||||
|
fun MainViewModel.placesOn(t: Timetable, date: LocalDate, subgroup: String?): List<DayPlace> =
|
||||||
|
repository.placesOn(t, date, subgroup, hidden)
|
||||||
|
|
||||||
|
/** The day's note above its lessons: the town in a tertiary card, with the subgroup it is for. */
|
||||||
|
@Composable
|
||||||
|
fun DayPlaces(places: List<DayPlace>, modifier: Modifier = Modifier) {
|
||||||
|
if (places.isEmpty()) return
|
||||||
|
Surface(
|
||||||
|
shape = MaterialTheme.shapes.large,
|
||||||
|
color = MaterialTheme.colorScheme.tertiaryContainer,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Column(Modifier.padding(horizontal = 16.dp, vertical = 12.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
|
places.forEach { p ->
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(Icons.Rounded.LocationCity, contentDescription = null, modifier = Modifier.size(24.dp))
|
||||||
|
Spacer(Modifier.width(14.dp))
|
||||||
|
Column {
|
||||||
|
Text(p.text, style = MaterialTheme.typography.titleMediumEmphasized)
|
||||||
|
Text(
|
||||||
|
listOfNotNull(p.subgroup, "пометка в Word на этот день").joinToString(" · ").replaceFirstChar { it.titlecase() },
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The town under a day's date in the week grid: short, in the note's colour. */
|
||||||
|
@Composable
|
||||||
|
fun DayPlaceMark(places: List<DayPlace>, modifier: Modifier = Modifier) {
|
||||||
|
val first = places.firstOrNull() ?: return
|
||||||
|
Surface(
|
||||||
|
shape = MaterialTheme.shapes.extraSmall,
|
||||||
|
color = MaterialTheme.colorScheme.tertiaryContainer,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||||
|
modifier = modifier.semantics { contentDescription = places.joinToString(", ") { listOfNotNull(it.text, it.subgroup).joinToString(", ") } },
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
first.place,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.padding(horizontal = 4.dp, vertical = 1.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -69,6 +69,7 @@ import androidx.compose.material.icons.rounded.ErrorOutline
|
|||||||
import androidx.compose.material.icons.rounded.EventBusy
|
import androidx.compose.material.icons.rounded.EventBusy
|
||||||
import androidx.compose.material.icons.rounded.EventRepeat
|
import androidx.compose.material.icons.rounded.EventRepeat
|
||||||
import androidx.compose.material.icons.rounded.Groups
|
import androidx.compose.material.icons.rounded.Groups
|
||||||
|
import androidx.compose.material.icons.rounded.LocationCity
|
||||||
import androidx.compose.material.icons.rounded.LocationOn
|
import androidx.compose.material.icons.rounded.LocationOn
|
||||||
import androidx.compose.material.icons.rounded.Person
|
import androidx.compose.material.icons.rounded.Person
|
||||||
import androidx.compose.material.icons.rounded.Refresh
|
import androidx.compose.material.icons.rounded.Refresh
|
||||||
@@ -101,13 +102,16 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.xtimms.psu.schedule.data.Bell
|
import org.xtimms.psu.schedule.data.Bell
|
||||||
|
import org.xtimms.psu.schedule.data.DayPlace
|
||||||
import org.xtimms.psu.schedule.data.Lesson
|
import org.xtimms.psu.schedule.data.Lesson
|
||||||
import org.xtimms.psu.schedule.data.Timetable
|
import org.xtimms.psu.schedule.data.Timetable
|
||||||
import org.xtimms.psu.schedule.data.dayName
|
import org.xtimms.psu.schedule.data.dayName
|
||||||
|
import org.xtimms.psu.schedule.data.forSubgroup
|
||||||
import org.xtimms.psu.schedule.data.minutesLabel
|
import org.xtimms.psu.schedule.data.minutesLabel
|
||||||
import org.xtimms.psu.schedule.data.monday
|
import org.xtimms.psu.schedule.data.monday
|
||||||
import org.xtimms.psu.schedule.data.parseDate
|
import org.xtimms.psu.schedule.data.parseDate
|
||||||
import org.xtimms.psu.schedule.data.today
|
import org.xtimms.psu.schedule.data.today
|
||||||
|
import org.xtimms.psu.schedule.data.withPlace
|
||||||
import java.time.Duration
|
import java.time.Duration
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
|
|
||||||
@@ -384,6 +388,7 @@ private fun Planner(
|
|||||||
DayPage(
|
DayPage(
|
||||||
date = pageDate,
|
date = pageDate,
|
||||||
slots = vm.slotsOn(t, pageDate, subgroup),
|
slots = vm.slotsOn(t, pageDate, subgroup),
|
||||||
|
places = vm.placesOn(t, pageDate, subgroup),
|
||||||
onOpen = onOpen,
|
onOpen = onOpen,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -402,6 +407,7 @@ private fun Planner(
|
|||||||
date = pageDate,
|
date = pageDate,
|
||||||
week = t.days.firstOrNull { it.date == pageDate.toString() }?.week,
|
week = t.days.firstOrNull { it.date == pageDate.toString() }?.week,
|
||||||
slots = vm.slotsOn(t, pageDate, subgroup),
|
slots = vm.slotsOn(t, pageDate, subgroup),
|
||||||
|
places = vm.placesOn(t, pageDate, subgroup),
|
||||||
chosen = stand?.takeIf { it.first == pageDate }?.second,
|
chosen = stand?.takeIf { it.first == pageDate }?.second,
|
||||||
clock = clock,
|
clock = clock,
|
||||||
onOpen = { onOpen(it, pageDate) },
|
onOpen = { onOpen(it, pageDate) },
|
||||||
@@ -431,7 +437,7 @@ private fun Planner(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun DayPage(date: LocalDate, slots: List<Slot>, onOpen: (Lesson, LocalDate) -> Unit) {
|
private fun DayPage(date: LocalDate, slots: List<Slot>, places: List<DayPlace>, onOpen: (Lesson, LocalDate) -> Unit) {
|
||||||
val clock = rememberClock()
|
val clock = rememberClock()
|
||||||
val isToday = date == today()
|
val isToday = date == today()
|
||||||
val next = if (isToday) slots.firstOrNull { it.start.isAfter(clock) } else null
|
val next = if (isToday) slots.firstOrNull { it.start.isAfter(clock) } else null
|
||||||
@@ -446,6 +452,8 @@ private fun DayPage(date: LocalDate, slots: List<Slot>, onOpen: (Lesson, LocalDa
|
|||||||
Empty("Занятий нет", "По таблице недель в этот день свободно — можно отдохнуть.", icon = Icons.Rounded.Weekend)
|
Empty("Занятий нет", "По таблице недель в этот день свободно — можно отдохнуть.", icon = Icons.Rounded.Weekend)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// "Занятия в Полоцке": the day's town before its lessons.
|
||||||
|
if (places.isNotEmpty()) item(key = "places") { AnimatedItem { DayPlaces(places) } }
|
||||||
slots.forEach { slot ->
|
slots.forEach { slot ->
|
||||||
val running = isToday && !clock.isBefore(slot.start) && clock.isBefore(slot.end)
|
val running = isToday && !clock.isBefore(slot.start) && clock.isBefore(slot.end)
|
||||||
val status = when {
|
val status = when {
|
||||||
@@ -508,10 +516,10 @@ fun LessonCard(
|
|||||||
time?.let { TimeText(it + (pair?.takeIf { show.pairs }?.let { p -> " · $p пара" } ?: ""), style = MaterialTheme.typography.bodyLarge) }
|
time?.let { TimeText(it + (pair?.takeIf { show.pairs }?.let { p -> " · $p пара" } ?: ""), style = MaterialTheme.typography.bodyLarge) }
|
||||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
FlowRow(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
if (lesson.distance) Meta(Icons.Rounded.LocationOn, "Дистанционно")
|
if (lesson.distance) Meta(Icons.Rounded.LocationOn, "Дистанционно")
|
||||||
else if (show.rooms && lesson.rooms.isNotEmpty()) Meta(
|
else withPlace(
|
||||||
Icons.Rounded.LocationOn,
|
lesson.rooms.takeIf { show.rooms && it.isNotEmpty() }?.let { it.joinToString(", ") + if (lesson.roomMoved) " (перенос)" else "" },
|
||||||
lesson.rooms.joinToString(", ") + if (lesson.roomMoved) " (перенос)" else "",
|
lesson.place,
|
||||||
)
|
)?.let { Meta(Icons.Rounded.LocationOn, it) }
|
||||||
if (show.teachers && lesson.teachers.isNotEmpty()) Meta(Icons.Rounded.Person, lesson.teachers.joinToString(", "))
|
if (show.teachers && lesson.teachers.isNotEmpty()) Meta(Icons.Rounded.Person, lesson.teachers.joinToString(", "))
|
||||||
lesson.subgroup?.let { Meta(Icons.Rounded.Groups, it) }
|
lesson.subgroup?.let { Meta(Icons.Rounded.Groups, it) }
|
||||||
}
|
}
|
||||||
@@ -644,6 +652,7 @@ internal fun LessonDetails(
|
|||||||
if (lesson.distance) "Дистанционно" else lesson.rooms.joinToString(", ").ifEmpty { "Не указана" },
|
if (lesson.distance) "Дистанционно" else lesson.rooms.joinToString(", ").ifEmpty { "Не указана" },
|
||||||
if (lesson.roomMoved) "В этот день — перенос аудитории" else null,
|
if (lesson.roomMoved) "В этот день — перенос аудитории" else null,
|
||||||
)
|
)
|
||||||
|
lesson.place?.let { Detail(Icons.Rounded.LocationCity, "Город", it, "Так указано в Word") }
|
||||||
Detail(Icons.Rounded.Groups, "Группы", lesson.groups.joinToString(", "), lesson.subgroup)
|
Detail(Icons.Rounded.Groups, "Группы", lesson.groups.joinToString(", "), lesson.subgroup)
|
||||||
if (lesson.uncertain) Banner(
|
if (lesson.uncertain) Banner(
|
||||||
"Пометку «${lesson.condition}» не удалось сопоставить с таблицей недель. Сверьте с Word.",
|
"Пометку «${lesson.condition}» не удалось сопоставить с таблицей недель. Сверьте с Word.",
|
||||||
@@ -702,6 +711,8 @@ private fun WordRows(t: Timetable, subgroup: String?, hidden: Set<String>, onOpe
|
|||||||
}
|
}
|
||||||
val lessons = day.lessons.filter { subgroup == null || it.subgroup == null || it.subgroup == subgroup }
|
val lessons = day.lessons.filter { subgroup == null || it.subgroup == null || it.subgroup == subgroup }
|
||||||
.filter { it.hideKey !in hidden }
|
.filter { it.hideKey !in hidden }
|
||||||
|
val places = if (lessons.isEmpty()) emptyList() else day.places.forSubgroup(subgroup)
|
||||||
|
if (places.isNotEmpty()) item(key = "p${day.day}") { DayPlaces(places) }
|
||||||
items(lessons, key = { "${day.day}-${it.key}" }) { lesson ->
|
items(lessons, key = { "${day.day}-${it.key}" }) { lesson ->
|
||||||
LessonCard(lesson, time = lesson.start + (lesson.end?.let { " → $it" } ?: ""), onClick = { onOpen(lesson) })
|
LessonCard(lesson, time = lesson.start + (lesson.end?.let { " → $it" } ?: ""), onClick = { onOpen(lesson) })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -261,6 +261,7 @@ fun SearchScreen(vm: MainViewModel, nav: AppNav) {
|
|||||||
teachers = if (target is Target.Room) l.teachers else emptyList(),
|
teachers = if (target is Target.Room) l.teachers else emptyList(),
|
||||||
rooms = if (target is Target.Teacher) l.rooms else emptyList(),
|
rooms = if (target is Target.Teacher) l.rooms else emptyList(),
|
||||||
roomMoved = l.moved,
|
roomMoved = l.moved,
|
||||||
|
place = l.place,
|
||||||
distance = l.distance,
|
distance = l.distance,
|
||||||
condition = l.condition,
|
condition = l.condition,
|
||||||
groups = l.groups,
|
groups = l.groups,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import androidx.compose.material.icons.automirrored.rounded.Assignment
|
|||||||
import androidx.compose.material.icons.rounded.Add
|
import androidx.compose.material.icons.rounded.Add
|
||||||
import androidx.compose.material3.FilledTonalButton
|
import androidx.compose.material3.FilledTonalButton
|
||||||
import org.xtimms.psu.schedule.data.Note
|
import org.xtimms.psu.schedule.data.Note
|
||||||
|
import org.xtimms.psu.schedule.data.withPlace
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.material.icons.rounded.DateRange
|
import androidx.compose.material.icons.rounded.DateRange
|
||||||
import androidx.compose.material.icons.rounded.Event
|
import androidx.compose.material.icons.rounded.Event
|
||||||
@@ -283,7 +284,7 @@ private fun SubjectCard(s: SubjectSummary, onClick: () -> Unit) {
|
|||||||
Fact(
|
Fact(
|
||||||
Icons.Rounded.Event,
|
Icons.Rounded.Event,
|
||||||
next?.let { (slot, lesson) ->
|
next?.let { (slot, lesson) ->
|
||||||
nextLabel(slot) + (lesson.rooms.takeIf { show.rooms && it.isNotEmpty() }?.let { ", ауд. ${it.joinToString(", ")}" } ?: "")
|
nextLabel(slot) + (withPlace(lesson.rooms.takeIf { show.rooms && it.isNotEmpty() }?.let { "ауд. ${it.joinToString(", ")}" }, lesson.place)?.let { ", $it" } ?: "")
|
||||||
} ?: "В загруженных неделях больше нет",
|
} ?: "В загруженных неделях больше нет",
|
||||||
strong = next != null,
|
strong = next != null,
|
||||||
)
|
)
|
||||||
@@ -360,7 +361,7 @@ private fun SubjectDetails(s: SubjectSummary, vm: MainViewModel) {
|
|||||||
Text(
|
Text(
|
||||||
listOfNotNull(
|
listOfNotNull(
|
||||||
lesson.type.ifEmpty { null },
|
lesson.type.ifEmpty { null },
|
||||||
if (lesson.distance) "дистанционно" else lesson.rooms.joinToString(", ").ifEmpty { null }?.let { "ауд. $it" },
|
if (lesson.distance) "дистанционно" else withPlace(lesson.rooms.joinToString(", ").ifEmpty { null }?.let { "ауд. $it" }, lesson.place),
|
||||||
lesson.teachers.joinToString(", ").ifEmpty { null },
|
lesson.teachers.joinToString(", ").ifEmpty { null },
|
||||||
).joinToString(" · "),
|
).joinToString(" · "),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
|||||||
@@ -37,7 +37,9 @@ import androidx.compose.ui.semantics.selected
|
|||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import org.xtimms.psu.schedule.data.DayPlace
|
||||||
import org.xtimms.psu.schedule.data.Lesson
|
import org.xtimms.psu.schedule.data.Lesson
|
||||||
|
import org.xtimms.psu.schedule.data.withPlace
|
||||||
import org.xtimms.psu.schedule.data.Week
|
import org.xtimms.psu.schedule.data.Week
|
||||||
import org.xtimms.psu.schedule.data.longLabel
|
import org.xtimms.psu.schedule.data.longLabel
|
||||||
import org.xtimms.psu.schedule.data.minutesLabel
|
import org.xtimms.psu.schedule.data.minutesLabel
|
||||||
@@ -65,6 +67,7 @@ fun StandDay(
|
|||||||
date: LocalDate,
|
date: LocalDate,
|
||||||
week: Week?,
|
week: Week?,
|
||||||
slots: List<Slot>,
|
slots: List<Slot>,
|
||||||
|
places: List<DayPlace>,
|
||||||
chosen: LocalTime?,
|
chosen: LocalTime?,
|
||||||
clock: LocalTime,
|
clock: LocalTime,
|
||||||
onOpen: (Lesson) -> Unit,
|
onOpen: (Lesson) -> Unit,
|
||||||
@@ -86,7 +89,11 @@ fun StandDay(
|
|||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
if (slots.isEmpty()) "Пар нет"
|
if (slots.isEmpty()) "Пар нет"
|
||||||
else "${slots.size} ${plural(slots.size, "пара", "пары", "пар")} · ${slots.first().start}–${slots.last().end}",
|
else listOfNotNull(
|
||||||
|
"${slots.size} ${plural(slots.size, "пара", "пары", "пар")} · ${slots.first().start}–${slots.last().end}",
|
||||||
|
// "Занятия в Полоцке", readable from across the desk with the day's span.
|
||||||
|
places.joinToString(", ") { listOfNotNull(it.text, it.subgroup?.lowercase()).joinToString(", ") }.ifEmpty { null },
|
||||||
|
).joinToString(" · "),
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.padding(bottom = 12.dp),
|
modifier = Modifier.padding(bottom = 12.dp),
|
||||||
@@ -158,14 +165,14 @@ private fun StandLesson(slot: Slot, clock: LocalTime, isToday: Boolean, onOpen:
|
|||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(20.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(20.dp)) {
|
||||||
val room = if (lesson.distance) "Дистанционно" else lesson.rooms.joinToString(", ").takeIf { show.rooms && it.isNotEmpty() }?.let { "Ауд. $it" }
|
val room = if (lesson.distance) "Дистанционно" else withPlace(lesson.rooms.joinToString(", ").takeIf { show.rooms && it.isNotEmpty() }?.let { "Ауд. $it" }, lesson.place)
|
||||||
room?.let { StandMeta(Icons.Rounded.LocationOn, it) }
|
room?.let { StandMeta(Icons.Rounded.LocationOn, it) }
|
||||||
if (show.teachers && lesson.teachers.isNotEmpty()) StandMeta(Icons.Rounded.Person, lesson.teachers.joinToString(", "))
|
if (show.teachers && lesson.teachers.isNotEmpty()) StandMeta(Icons.Rounded.Person, lesson.teachers.joinToString(", "))
|
||||||
}
|
}
|
||||||
// Parallel subgroups share the slot: the others in a line.
|
// Parallel subgroups share the slot: the others in a line.
|
||||||
slot.lessons.drop(1).forEach { other ->
|
slot.lessons.drop(1).forEach { other ->
|
||||||
Text(
|
Text(
|
||||||
listOfNotNull(other.subgroup, other.subject, other.rooms.joinToString(", ").takeIf { it.isNotEmpty() }?.let { "ауд. $it" })
|
listOfNotNull(other.subgroup, other.subject, withPlace(other.rooms.joinToString(", ").takeIf { it.isNotEmpty() }?.let { "ауд. $it" }, other.place))
|
||||||
.joinToString(" · "),
|
.joinToString(" · "),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
@@ -271,7 +278,8 @@ fun StandLessons(
|
|||||||
color = if (past && !selected) MaterialTheme.colorScheme.onSurfaceVariant else Color.Unspecified,
|
color = if (past && !selected) MaterialTheme.colorScheme.onSurfaceVariant else Color.Unspecified,
|
||||||
)
|
)
|
||||||
val room = slot.lessons.flatMap { it.rooms }.distinct().joinToString(", ")
|
val room = slot.lessons.flatMap { it.rooms }.distinct().joinToString(", ")
|
||||||
if (room.isNotEmpty()) Text("ауд. $room", style = MaterialTheme.typography.bodySmall, maxLines = 1)
|
withPlace(room.takeIf { it.isNotEmpty() }?.let { "ауд. $it" }, slot.lessons.firstNotNullOfOrNull { it.place })
|
||||||
|
?.let { Text(it, style = MaterialTheme.typography.bodySmall, maxLines = 1) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import org.xtimms.psu.schedule.data.longLabel
|
|||||||
import org.xtimms.psu.schedule.data.shortWeekday
|
import org.xtimms.psu.schedule.data.shortWeekday
|
||||||
import org.xtimms.psu.schedule.data.today
|
import org.xtimms.psu.schedule.data.today
|
||||||
import org.xtimms.psu.schedule.data.weekdayName
|
import org.xtimms.psu.schedule.data.weekdayName
|
||||||
|
import org.xtimms.psu.schedule.data.withPlace
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
import java.time.LocalTime
|
import java.time.LocalTime
|
||||||
|
|
||||||
@@ -88,6 +89,7 @@ fun WeekGrid(
|
|||||||
) {
|
) {
|
||||||
days.forEach { (d, _) ->
|
days.forEach { (d, _) ->
|
||||||
val isToday = d == today()
|
val isToday = d == today()
|
||||||
|
val places = vm.placesOn(t, d, subgroup)
|
||||||
Column(
|
Column(
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -113,6 +115,8 @@ fun WeekGrid(
|
|||||||
color = if (isToday) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface,
|
color = if (isToday) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// "Занятия в Полоцке": the town of the whole day, under its date.
|
||||||
|
DayPlaceMark(places, Modifier.padding(top = 2.dp, start = 2.dp, end = 2.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -175,10 +179,12 @@ fun WeekGrid(
|
|||||||
private fun LessonBlock(lesson: Lesson, slot: Slot, wide: Boolean, notes: Int, onClick: () -> Unit, modifier: Modifier) {
|
private fun LessonBlock(lesson: Lesson, slot: Slot, wide: Boolean, notes: Int, onClick: () -> Unit, modifier: Modifier) {
|
||||||
val color = subjectColor(lesson.subject)
|
val color = subjectColor(lesson.subject)
|
||||||
val session = lesson.session
|
val session = lesson.session
|
||||||
|
val rooms = lesson.rooms.joinToString(", ").takeIf { LocalDisplay.current.rooms }.orEmpty()
|
||||||
|
// A narrow column keeps the room whole: the day's town is under its date.
|
||||||
val room = when {
|
val room = when {
|
||||||
lesson.distance -> "дист."
|
lesson.distance -> "дист."
|
||||||
LocalDisplay.current.rooms -> lesson.rooms.joinToString(", ")
|
wide -> withPlace(rooms, lesson.place).orEmpty()
|
||||||
else -> ""
|
else -> rooms.ifEmpty { lesson.place.orEmpty() }
|
||||||
}
|
}
|
||||||
Surface(
|
Surface(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
|
|||||||
@@ -69,8 +69,9 @@ class NextLessonWidget : GlanceAppWidget() {
|
|||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 15.sp, fontWeight = FontWeight.Bold),
|
style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 15.sp, fontWeight = FontWeight.Bold),
|
||||||
)
|
)
|
||||||
if (next.rooms.isNotEmpty()) Text(
|
next.where?.let { where ->
|
||||||
"ауд. ${next.rooms}",
|
Text(
|
||||||
|
where,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 13.sp),
|
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 13.sp),
|
||||||
)
|
)
|
||||||
@@ -78,6 +79,7 @@ class NextLessonWidget : GlanceAppWidget() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class NextLessonWidgetReceiver : GlanceAppWidgetReceiver() {
|
class NextLessonWidgetReceiver : GlanceAppWidgetReceiver() {
|
||||||
override val glanceAppWidget: GlanceAppWidget = NextLessonWidget()
|
override val glanceAppWidget: GlanceAppWidget = NextLessonWidget()
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import org.xtimms.psu.schedule.data.nowTime
|
|||||||
import org.xtimms.psu.schedule.data.parseTime
|
import org.xtimms.psu.schedule.data.parseTime
|
||||||
import org.xtimms.psu.schedule.data.today
|
import org.xtimms.psu.schedule.data.today
|
||||||
import org.xtimms.psu.schedule.data.weekdayName
|
import org.xtimms.psu.schedule.data.weekdayName
|
||||||
|
import org.xtimms.psu.schedule.data.withPlace
|
||||||
import java.time.Duration
|
import java.time.Duration
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
|
|
||||||
@@ -71,8 +72,11 @@ private suspend fun state(context: Context): WidgetState {
|
|||||||
"$start · через ${minutesLabel(Duration.between(now, begins).toMinutes())}"
|
"$start · через ${minutesLabel(Duration.between(now, begins).toMinutes())}"
|
||||||
else -> start + (l.pair?.let { " · $it пара" } ?: "")
|
else -> start + (l.pair?.let { " · $it пара" } ?: "")
|
||||||
}
|
}
|
||||||
val place = lessons.flatMap { it.rooms }.distinct().joinToString(", ")
|
val place = withPlace(
|
||||||
label to (lessons.joinToString(" / ") { it.subject } + if (place.isNotEmpty()) " · ауд. $place" else "")
|
lessons.flatMap { it.rooms }.distinct().joinToString(", ").takeIf { it.isNotEmpty() }?.let { "ауд. $it" },
|
||||||
|
lessons.firstNotNullOfOrNull { it.place },
|
||||||
|
)
|
||||||
|
label to (lessons.joinToString(" / ") { it.subject } + (place?.let { " · $it" } ?: ""))
|
||||||
}
|
}
|
||||||
return WidgetState(
|
return WidgetState(
|
||||||
timetable.group,
|
timetable.group,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import org.xtimms.psu.schedule.data.nowTime
|
|||||||
import org.xtimms.psu.schedule.data.parseTime
|
import org.xtimms.psu.schedule.data.parseTime
|
||||||
import org.xtimms.psu.schedule.data.shortWeekday
|
import org.xtimms.psu.schedule.data.shortWeekday
|
||||||
import org.xtimms.psu.schedule.data.today
|
import org.xtimms.psu.schedule.data.today
|
||||||
|
import org.xtimms.psu.schedule.data.withPlace
|
||||||
import java.time.Duration
|
import java.time.Duration
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
|
|
||||||
@@ -15,6 +16,8 @@ import java.time.LocalDate
|
|||||||
data class Upcoming(val date: LocalDate, val start: String, val end: String?, val lessons: List<Lesson>, val running: Boolean) {
|
data class Upcoming(val date: LocalDate, val start: String, val end: String?, val lessons: List<Lesson>, val running: Boolean) {
|
||||||
val subject get() = lessons.joinToString(" / ") { it.subject }
|
val subject get() = lessons.joinToString(" / ") { it.subject }
|
||||||
val rooms get() = lessons.flatMap { if (it.distance) listOf("дист.") else it.rooms }.distinct().joinToString(", ")
|
val rooms get() = lessons.flatMap { if (it.distance) listOf("дист.") else it.rooms }.distinct().joinToString(", ")
|
||||||
|
/** "ауд. 203Г · Полоцк": the rooms with the town the document names. */
|
||||||
|
val where get() = withPlace(rooms.takeIf { it.isNotEmpty() }?.let { "ауд. $it" }, lessons.firstNotNullOfOrNull { it.place })
|
||||||
|
|
||||||
/** "Идёт · ещё 40 мин", "Через 20 мин", "Завтра, 10:05" or "Пн, 10:05". */
|
/** "Идёт · ещё 40 мин", "Через 20 мин", "Завтра, 10:05" or "Пн, 10:05". */
|
||||||
fun whenLabel(): String {
|
fun whenLabel(): String {
|
||||||
|
|||||||
@@ -65,6 +65,11 @@ final class AppModel {
|
|||||||
t.slots(on: date, subgroup: subgroup, hidden: hidden)
|
t.slots(on: date, subgroup: subgroup, hidden: hidden)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** "Занятия в Полоцке" over the date, for the student's subgroup. */
|
||||||
|
func places(_ t: Timetable, on date: CivilDate) -> [DayPlace] {
|
||||||
|
t.places(on: date, subgroup: subgroup, hidden: hidden)
|
||||||
|
}
|
||||||
|
|
||||||
/** The open group's last republished document: what changed for it. */
|
/** The open group's last republished document: what changed for it. */
|
||||||
var changes: ScheduleChanges? {
|
var changes: ScheduleChanges? {
|
||||||
guard let t = timetable else { return nil }
|
guard let t = timetable else { return nil }
|
||||||
|
|||||||
@@ -35,10 +35,11 @@ enum Reminders {
|
|||||||
let content = UNMutableNotificationContent()
|
let content = UNMutableNotificationContent()
|
||||||
content.title = slot.lessons.map(\.subject).joined(separator: " / ")
|
content.title = slot.lessons.map(\.subject).joined(separator: " / ")
|
||||||
let first = slot.lessons[0]
|
let first = slot.lessons[0]
|
||||||
let place = Array(Set(slot.lessons.flatMap(\.rooms))).sorted().joined(separator: ", ")
|
let rooms = Array(Set(slot.lessons.flatMap(\.rooms))).sorted().joined(separator: ", ")
|
||||||
|
let place = withPlace(rooms.isEmpty ? nil : "ауд. " + rooms, slot.lessons.compactMap(\.place).first)
|
||||||
var text = slot.start.description
|
var text = slot.start.description
|
||||||
if let pair = first.pair { text += " · \(pair) пара" }
|
if let pair = first.pair { text += " · \(pair) пара" }
|
||||||
if !place.isEmpty { text += " · ауд. \(place)" }
|
if let place { text += " · \(place)" }
|
||||||
if slot.lessons.contains(where: \.uncertain) { text += " · сверьте недели" }
|
if slot.lessons.contains(where: \.uncertain) { text += " · сверьте недели" }
|
||||||
content.body = text
|
content.body = text
|
||||||
content.sound = .default
|
content.sound = .default
|
||||||
|
|||||||
@@ -225,6 +225,7 @@ private struct DayAgenda: View {
|
|||||||
}
|
}
|
||||||
if !slots.isEmpty {
|
if !slots.isEmpty {
|
||||||
Text("Ваши занятия").font(.headline)
|
Text("Ваши занятия").font(.headline)
|
||||||
|
if let t = model.timetable { DayPlaces(places: model.places(t, on: date)) }
|
||||||
ForEach(slots) { slot in
|
ForEach(slots) { slot in
|
||||||
ForEach(slot.lessons, id: \.key) { lesson in
|
ForEach(slot.lessons, id: \.key) { lesson in
|
||||||
Button { details = LessonRef(lesson: lesson, date: date) } label: {
|
Button { details = LessonRef(lesson: lesson, date: date) } label: {
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ struct ChangesSheet: View {
|
|||||||
Section("Добавлено · \(record.added.count)") {
|
Section("Добавлено · \(record.added.count)") {
|
||||||
ForEach(record.added, id: \.self) { row in
|
ForEach(record.added, id: \.self) { row in
|
||||||
item(row, details: [row.lesson.type, row.lesson.teachers.joined(separator: ", "),
|
item(row, details: [row.lesson.type, row.lesson.teachers.joined(separator: ", "),
|
||||||
row.lesson.rooms.map { "ауд. \($0)" }.joined(separator: ", "), row.lesson.condition])
|
withPlace(row.lesson.rooms.map { "ауд. \($0)" }.joined(separator: ", "), row.lesson.place) ?? "", row.lesson.condition])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -142,6 +142,55 @@ struct Banner: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// "Занятия в Полоцке" above a day's lessons: Word marks the days a group spends in another
|
||||||
|
/// town, and every lesson of that day is there.
|
||||||
|
struct DayPlaces: View {
|
||||||
|
var places: [DayPlace]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if !places.isEmpty {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
ForEach(places, id: \.self) { p in
|
||||||
|
HStack(spacing: 14) {
|
||||||
|
Image(systemName: "building.2")
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(Color.indigo)
|
||||||
|
.accessibilityHidden(true)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(p.text).font(.headline)
|
||||||
|
Text(p.subgroup.map { "\($0) · пометка в Word на этот день" } ?? "Пометка в Word на этот день")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.accessibilityElement(children: .combine)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.background(Color.indigo.opacity(0.14), in: .rect(cornerRadius: 18, style: .continuous))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The town under a day's date in the week grid: short, in the note's colour.
|
||||||
|
struct DayPlaceMark: View {
|
||||||
|
var places: [DayPlace]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if let first = places.first {
|
||||||
|
Text(first.place)
|
||||||
|
.font(.caption2.weight(.semibold))
|
||||||
|
.lineLimit(1)
|
||||||
|
.padding(.horizontal, 4)
|
||||||
|
.padding(.vertical, 1)
|
||||||
|
.foregroundStyle(Color.indigo)
|
||||||
|
.background(Color.indigo.opacity(0.14), in: .rect(cornerRadius: 4, style: .continuous))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The status banner of the app model, if it has something to say.
|
/// The status banner of the app model, if it has something to say.
|
||||||
struct StatusBanner: View {
|
struct StatusBanner: View {
|
||||||
@Environment(AppModel.self) private var model
|
@Environment(AppModel.self) private var model
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ struct HomeView: View {
|
|||||||
WeekReport(timetable: t)
|
WeekReport(timetable: t)
|
||||||
SessionCard(timetable: t) { session = true }
|
SessionCard(timetable: t) { session = true }
|
||||||
DayTitle(label: "Сегодня", date: today)
|
DayTitle(label: "Сегодня", date: today)
|
||||||
TodayCard(slots: todaySlots, onOpen: { details = LessonRef(lesson: $0, date: today) }, onDay: { router.openDay(today) })
|
TodayCard(slots: todaySlots, places: model.places(t, on: today), onOpen: { details = LessonRef(lesson: $0, date: today) }, onDay: { router.openDay(today) })
|
||||||
AttendanceCard(slots: todaySlots)
|
AttendanceCard(slots: todaySlots)
|
||||||
NavigationLink(value: HomeRoute.subjects) { SubjectsLink(timetable: t) }
|
NavigationLink(value: HomeRoute.subjects) { SubjectsLink(timetable: t) }
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
@@ -99,6 +99,7 @@ struct HomeView: View {
|
|||||||
let (day, slots) = next
|
let (day, slots) = next
|
||||||
DayTitle(label: day == today.adding(days: 1) ? "Завтра" : day.weekdayName, date: day)
|
DayTitle(label: day == today.adding(days: 1) ? "Завтра" : day.weekdayName, date: day)
|
||||||
SectionCard(symbol: "calendar.badge.checkmark", title: pairs(slots.count)) {
|
SectionCard(symbol: "calendar.badge.checkmark", title: pairs(slots.count)) {
|
||||||
|
DayPlaces(places: model.places(t, on: day))
|
||||||
ForEach(slots) { slot in
|
ForEach(slots) { slot in
|
||||||
ForEach(slot.lessons, id: \.key) { lesson in
|
ForEach(slot.lessons, id: \.key) { lesson in
|
||||||
Button { details = LessonRef(lesson: lesson, date: day) } label: { CompactLesson(lesson: lesson, slot: slot) }
|
Button { details = LessonRef(lesson: lesson, date: day) } label: { CompactLesson(lesson: lesson, slot: slot) }
|
||||||
@@ -264,6 +265,7 @@ private struct DayChip: View {
|
|||||||
private struct TodayCard: View {
|
private struct TodayCard: View {
|
||||||
@Environment(AppModel.self) private var model
|
@Environment(AppModel.self) private var model
|
||||||
var slots: [Slot]
|
var slots: [Slot]
|
||||||
|
var places: [DayPlace]
|
||||||
var onOpen: (Lesson) -> Void
|
var onOpen: (Lesson) -> Void
|
||||||
var onDay: () -> Void
|
var onDay: () -> Void
|
||||||
|
|
||||||
@@ -276,6 +278,7 @@ private struct TodayCard: View {
|
|||||||
} else if left.isEmpty {
|
} else if left.isEmpty {
|
||||||
Quiet(title: "Пары на сегодня закончились", text: "Сегодня было \(pairs(slots.count)).")
|
Quiet(title: "Пары на сегодня закончились", text: "Сегодня было \(pairs(slots.count)).")
|
||||||
} else {
|
} else {
|
||||||
|
DayPlaces(places: places)
|
||||||
let first = left[0]
|
let first = left[0]
|
||||||
ForEach(first.lessons, id: \.key) { lesson in
|
ForEach(first.lessons, id: \.key) { lesson in
|
||||||
Button { onOpen(lesson) } label: { FeaturedLesson(lesson: lesson, slot: first, clock: clock) }
|
Button { onOpen(lesson) } label: { FeaturedLesson(lesson: lesson, slot: first, clock: clock) }
|
||||||
@@ -328,7 +331,9 @@ private struct FeaturedLesson: View {
|
|||||||
.background(running ? Color.brand : Color.brand.opacity(0.14), in: .capsule)
|
.background(running ? Color.brand : Color.brand.opacity(0.14), in: .capsule)
|
||||||
}
|
}
|
||||||
if lesson.distance { Meta(symbol: "mappin.and.ellipse", text: "Дистанционно") }
|
if lesson.distance { Meta(symbol: "mappin.and.ellipse", text: "Дистанционно") }
|
||||||
else if show.rooms && !lesson.rooms.isEmpty { Meta(symbol: "mappin.and.ellipse", text: "Ауд. " + lesson.rooms.joined(separator: ", ")) }
|
else if let room = withPlace(show.rooms && !lesson.rooms.isEmpty ? "Ауд. " + lesson.rooms.joined(separator: ", ") : nil, lesson.place) {
|
||||||
|
Meta(symbol: "mappin.and.ellipse", text: room)
|
||||||
|
}
|
||||||
if show.teachers && !lesson.teachers.isEmpty { Meta(symbol: "person", text: lesson.teachers.joined(separator: ", ")) }
|
if show.teachers && !lesson.teachers.isEmpty { Meta(symbol: "person", text: lesson.teachers.joined(separator: ", ")) }
|
||||||
}
|
}
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
|
|||||||
@@ -38,8 +38,11 @@ struct LessonCard: View {
|
|||||||
}
|
}
|
||||||
FlowLayout(spacing: 14) {
|
FlowLayout(spacing: 14) {
|
||||||
if lesson.distance { Meta(symbol: "mappin.and.ellipse", text: "Дистанционно") }
|
if lesson.distance { Meta(symbol: "mappin.and.ellipse", text: "Дистанционно") }
|
||||||
else if show.rooms && !lesson.rooms.isEmpty {
|
else if let room = withPlace(
|
||||||
Meta(symbol: "mappin.and.ellipse", text: lesson.rooms.joined(separator: ", ") + (lesson.roomMoved ? " (перенос)" : ""))
|
show.rooms && !lesson.rooms.isEmpty ? lesson.rooms.joined(separator: ", ") + (lesson.roomMoved ? " (перенос)" : "") : nil,
|
||||||
|
lesson.place
|
||||||
|
) {
|
||||||
|
Meta(symbol: "mappin.and.ellipse", text: room)
|
||||||
}
|
}
|
||||||
if show.teachers && !lesson.teachers.isEmpty { Meta(symbol: "person", text: lesson.teachers.joined(separator: ", ")) }
|
if show.teachers && !lesson.teachers.isEmpty { Meta(symbol: "person", text: lesson.teachers.joined(separator: ", ")) }
|
||||||
if let sub = lesson.subgroup { Meta(symbol: "person.2", text: sub) }
|
if let sub = lesson.subgroup { Meta(symbol: "person.2", text: sub) }
|
||||||
@@ -123,7 +126,7 @@ struct CompactLesson: View {
|
|||||||
if let session = lesson.session { parts.insert(session.label, at: 0) }
|
if let session = lesson.session { parts.insert(session.label, at: 0) }
|
||||||
if let sub = lesson.subgroup, lesson.session != nil { parts.append(sub.lowercased().replacingOccurrences(of: "подгруппа", with: "подгр.")) }
|
if let sub = lesson.subgroup, lesson.session != nil { parts.append(sub.lowercased().replacingOccurrences(of: "подгруппа", with: "подгр.")) }
|
||||||
if lesson.distance { parts.append("дистанционно") }
|
if lesson.distance { parts.append("дистанционно") }
|
||||||
else if show.rooms && !lesson.rooms.isEmpty { parts.append("ауд. " + lesson.rooms.joined(separator: ", ")) }
|
else if let room = withPlace(show.rooms && !lesson.rooms.isEmpty ? "ауд. " + lesson.rooms.joined(separator: ", ") : nil, lesson.place) { parts.append(room) }
|
||||||
if show.tags && !lesson.type.isEmpty && lesson.session == nil { parts.append(lesson.type.lowercased()) }
|
if show.tags && !lesson.type.isEmpty && lesson.session == nil { parts.append(lesson.type.lowercased()) }
|
||||||
return parts.joined(separator: " · ")
|
return parts.joined(separator: " · ")
|
||||||
}
|
}
|
||||||
@@ -163,6 +166,9 @@ struct LessonDetails: View {
|
|||||||
value: lesson.distance ? "Дистанционно" : (lesson.rooms.isEmpty ? "Не указана" : lesson.rooms.joined(separator: ", ")),
|
value: lesson.distance ? "Дистанционно" : (lesson.rooms.isEmpty ? "Не указана" : lesson.rooms.joined(separator: ", ")),
|
||||||
extra: lesson.roomMoved ? "В этот день — перенос аудитории" : nil
|
extra: lesson.roomMoved ? "В этот день — перенос аудитории" : nil
|
||||||
)
|
)
|
||||||
|
if let place = lesson.place {
|
||||||
|
DetailRow(symbol: "building.2", label: "Город", value: place, extra: "Так указано в Word")
|
||||||
|
}
|
||||||
DetailRow(symbol: "person.3", label: "Группы", value: lesson.groups.joined(separator: ", "), extra: lesson.subgroup)
|
DetailRow(symbol: "person.3", label: "Группы", value: lesson.groups.joined(separator: ", "), extra: lesson.subgroup)
|
||||||
}
|
}
|
||||||
.card()
|
.card()
|
||||||
|
|||||||
@@ -153,6 +153,7 @@ private struct DayPage: View {
|
|||||||
EmptyState(title: "Занятий нет", text: "По таблице недель в этот день свободно — можно отдохнуть.", symbol: "sofa")
|
EmptyState(title: "Занятий нет", text: "По таблице недель в этот день свободно — можно отдохнуть.", symbol: "sofa")
|
||||||
.padding(.top, 60)
|
.padding(.top, 60)
|
||||||
}
|
}
|
||||||
|
DayPlaces(places: model.places(timetable, on: date))
|
||||||
ForEach(slots) { slot in
|
ForEach(slots) { slot in
|
||||||
let running = isToday && clock >= slot.start && clock < slot.end
|
let running = isToday && clock >= slot.start && clock < slot.end
|
||||||
let status: String? = running
|
let status: String? = running
|
||||||
@@ -281,6 +282,7 @@ private struct WordRows: View {
|
|||||||
let lessons = day.lessons
|
let lessons = day.lessons
|
||||||
.filter { model.subgroup == nil || $0.subgroup == nil || $0.subgroup == model.subgroup }
|
.filter { model.subgroup == nil || $0.subgroup == nil || $0.subgroup == model.subgroup }
|
||||||
.filter { !model.hidden.contains($0.hideKey) }
|
.filter { !model.hidden.contains($0.hideKey) }
|
||||||
|
if !lessons.isEmpty { DayPlaces(places: day.places.forSubgroup(model.subgroup)) }
|
||||||
ForEach(lessons, id: \.key) { lesson in
|
ForEach(lessons, id: \.key) { lesson in
|
||||||
Button { onOpen(lesson) } label: {
|
Button { onOpen(lesson) } label: {
|
||||||
LessonCard(lesson: lesson, time: lesson.start + (lesson.end.map { " → \($0)" } ?? ""), date: nil)
|
LessonCard(lesson: lesson, time: lesson.start + (lesson.end.map { " → \($0)" } ?? ""), date: nil)
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ private struct DirectoryLessonCard: View {
|
|||||||
Text(lesson.start + (lesson.end.map { " → \($0)" } ?? "") + (pair.map { " · \($0) пара" } ?? "")).font(.body.monospacedDigit())
|
Text(lesson.start + (lesson.end.map { " → \($0)" } ?? "") + (pair.map { " · \($0) пара" } ?? "")).font(.body.monospacedDigit())
|
||||||
FlowLayout(spacing: 14) {
|
FlowLayout(spacing: 14) {
|
||||||
if case .teacher = target {
|
if case .teacher = target {
|
||||||
Meta(symbol: "mappin.and.ellipse", text: lesson.distance ? "Дистанционно" : lesson.rooms.joined(separator: ", ") + (lesson.moved ? " (перенос)" : ""))
|
Meta(symbol: "mappin.and.ellipse", text: lesson.distance ? "Дистанционно" : withPlace(lesson.rooms.joined(separator: ", ") + (lesson.moved ? " (перенос)" : ""), lesson.place) ?? "")
|
||||||
} else if !lesson.teachers.isEmpty {
|
} else if !lesson.teachers.isEmpty {
|
||||||
Meta(symbol: "person", text: lesson.teachers.joined(separator: ", "))
|
Meta(symbol: "person", text: lesson.teachers.joined(separator: ", "))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ private struct SubjectCard: View {
|
|||||||
}
|
}
|
||||||
if let first = summary.upcoming.first {
|
if let first = summary.upcoming.first {
|
||||||
let (slot, lesson) = first
|
let (slot, lesson) = first
|
||||||
Meta(symbol: "clock", text: nextLabel(slot) + (lesson.rooms.isEmpty ? "" : " · ауд. " + lesson.rooms.joined(separator: ", ")))
|
Meta(symbol: "clock", text: nextLabel(slot) + (withPlace(lesson.rooms.isEmpty ? nil : "ауд. " + lesson.rooms.joined(separator: ", "), lesson.place).map { " · " + $0 } ?? ""))
|
||||||
} else {
|
} else {
|
||||||
Meta(symbol: "checkmark.circle", text: "Занятий в этом семестре больше нет")
|
Meta(symbol: "checkmark.circle", text: "Занятий в этом семестре больше нет")
|
||||||
}
|
}
|
||||||
@@ -211,7 +211,7 @@ private struct SubjectDetails: View {
|
|||||||
Button { details = LessonRef(lesson: item.1, date: item.0.date) } label: {
|
Button { details = LessonRef(lesson: item.1, date: item.0.date) } label: {
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(item.0.date.weekdayName + ", " + item.0.date.longLabel).font(.subheadline.weight(.semibold))
|
Text(item.0.date.weekdayName + ", " + item.0.date.longLabel).font(.subheadline.weight(.semibold))
|
||||||
Text([item.0.timeLabel, item.1.type.isEmpty ? nil : item.1.type.lowercased(), item.1.rooms.isEmpty ? nil : "ауд. " + item.1.rooms.joined(separator: ", ")].compactMap { $0 }.joined(separator: " · "))
|
Text([item.0.timeLabel, item.1.type.isEmpty ? nil : item.1.type.lowercased(), withPlace(item.1.rooms.isEmpty ? nil : "ауд. " + item.1.rooms.joined(separator: ", "), item.1.place)].compactMap { $0 }.joined(separator: " · "))
|
||||||
.font(.subheadline).foregroundStyle(.secondary)
|
.font(.subheadline).foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,11 +21,13 @@ struct WeekGrid: View {
|
|||||||
let today = model.today
|
let today = model.today
|
||||||
|
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
HStack(spacing: 0) {
|
// Dates stay on one line when a day has the town under it.
|
||||||
|
HStack(alignment: .top, spacing: 0) {
|
||||||
Color.clear.frame(width: gutter, height: 1)
|
Color.clear.frame(width: gutter, height: 1)
|
||||||
ForEach(days, id: \.0) { day in
|
ForEach(days, id: \.0) { day in
|
||||||
let d = day.0
|
let d = day.0
|
||||||
let isToday = d == today
|
let isToday = d == today
|
||||||
|
let places = model.places(timetable, on: d)
|
||||||
VStack(spacing: 2) {
|
VStack(spacing: 2) {
|
||||||
Text(d.shortWeekday).font(.caption.weight(.medium)).foregroundStyle(isToday ? Color.brand : .secondary)
|
Text(d.shortWeekday).font(.caption.weight(.medium)).foregroundStyle(isToday ? Color.brand : .secondary)
|
||||||
Text("\(d.day)")
|
Text("\(d.day)")
|
||||||
@@ -33,10 +35,12 @@ struct WeekGrid: View {
|
|||||||
.foregroundStyle(isToday ? Color.onBrand : .primary)
|
.foregroundStyle(isToday ? Color.onBrand : .primary)
|
||||||
.frame(width: 34, height: 34)
|
.frame(width: 34, height: 34)
|
||||||
.background(isToday ? Color.brand : .clear, in: .circle)
|
.background(isToday ? Color.brand : .clear, in: .circle)
|
||||||
|
// "Занятия в Полоцке": the town of the whole day, under its date.
|
||||||
|
DayPlaceMark(places: places)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.accessibilityElement(children: .ignore)
|
.accessibilityElement(children: .ignore)
|
||||||
.accessibilityLabel(d.weekdayName + ", " + d.longLabel)
|
.accessibilityLabel(d.weekdayName + ", " + d.longLabel + places.map { ", " + $0.text }.joined())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.bottom, 8)
|
.padding(.bottom, 8)
|
||||||
@@ -117,7 +121,8 @@ private struct LessonBlock: View {
|
|||||||
Text(wide ? lesson.subject : abbreviate(lesson.subject))
|
Text(wide ? lesson.subject : abbreviate(lesson.subject))
|
||||||
.font(.caption.weight(.semibold))
|
.font(.caption.weight(.semibold))
|
||||||
.lineLimit(wide ? 3 : 2)
|
.lineLimit(wide ? 3 : 2)
|
||||||
if show.rooms, let room = lesson.distance ? "дист." : lesson.rooms.first {
|
// A narrow column keeps the room whole: the day's town is under its date.
|
||||||
|
if let room = lesson.distance ? (show.rooms ? "дист." : nil) : (wide ? withPlace(show.rooms ? lesson.rooms.first : nil, lesson.place) : (show.rooms ? lesson.rooms.first : nil) ?? lesson.place) {
|
||||||
Text(room).font(.caption2).lineLimit(1).opacity(0.9)
|
Text(room).font(.caption2).lineLimit(1).opacity(0.9)
|
||||||
}
|
}
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
@@ -140,6 +145,6 @@ private struct LessonBlock: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.accessibilityElement(children: .ignore)
|
.accessibilityElement(children: .ignore)
|
||||||
.accessibilityLabel((lesson.session.map { "\($0.label): " } ?? "") + "\(lesson.subject), \(lesson.start)" + (lesson.rooms.isEmpty ? "" : ", ауд. " + lesson.rooms.joined(separator: ", ")))
|
.accessibilityLabel((lesson.session.map { "\($0.label): " } ?? "") + "\(lesson.subject), \(lesson.start)" + (withPlace(lesson.rooms.isEmpty ? nil : "ауд. " + lesson.rooms.joined(separator: ", "), lesson.place).map { ", " + $0 } ?? ""))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ private func fields(_ a: ChangedLesson, _ b: ChangedLesson) -> [FieldChange] {
|
|||||||
("Вид занятия", a.lesson.type, b.lesson.type),
|
("Вид занятия", a.lesson.type, b.lesson.type),
|
||||||
("Преподаватель", a.lesson.teachers.joined(separator: ", "), b.lesson.teachers.joined(separator: ", ")),
|
("Преподаватель", a.lesson.teachers.joined(separator: ", "), b.lesson.teachers.joined(separator: ", ")),
|
||||||
("Аудитория", a.lesson.rooms.joined(separator: ", "), b.lesson.rooms.joined(separator: ", ")),
|
("Аудитория", a.lesson.rooms.joined(separator: ", "), b.lesson.rooms.joined(separator: ", ")),
|
||||||
|
("Город", a.lesson.place ?? "", b.lesson.place ?? ""),
|
||||||
]
|
]
|
||||||
let shown = { (s: String) in s.trimmed.isEmpty ? "не указано" : s }
|
let shown = { (s: String) in s.trimmed.isEmpty ? "не указано" : s }
|
||||||
return pairs.filter { $0.1.trimmed != $0.2.trimmed }.map { FieldChange(label: $0.0, from: shown($0.1), to: shown($0.2)) }
|
return pairs.filter { $0.1.trimmed != $0.2.trimmed }.map { FieldChange(label: $0.0, from: shown($0.1), to: shown($0.2)) }
|
||||||
|
|||||||
@@ -51,11 +51,39 @@ struct Day: Codable, Hashable {
|
|||||||
var weekday: Int
|
var weekday: Int
|
||||||
var week: Week?
|
var week: Week?
|
||||||
var lessons: [Lesson] = []
|
var lessons: [Lesson] = []
|
||||||
|
var places: [DayPlace] = []
|
||||||
}
|
}
|
||||||
|
|
||||||
struct WeeklyDay: Codable, Hashable {
|
struct WeeklyDay: Codable, Hashable {
|
||||||
var day: Int
|
var day: Int
|
||||||
var lessons: [Lesson] = []
|
var lessons: [Lesson] = []
|
||||||
|
var places: [DayPlace] = []
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Занятия в Полоцке" over a day in Word: that day the group's lessons (or one subgroup's,
|
||||||
|
* when `subgroup` is set) are in another town (server/timetable.js, shared/places.js).
|
||||||
|
*/
|
||||||
|
struct DayPlace: Codable, Hashable {
|
||||||
|
var place: String
|
||||||
|
var text: String
|
||||||
|
var subgroup: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Array where Element == DayPlace {
|
||||||
|
/** The notes for the student's subgroup; a note for their own subgroup reads as the whole day's. */
|
||||||
|
func forSubgroup(_ subgroup: String?) -> [DayPlace] {
|
||||||
|
var seen = Set<DayPlace>()
|
||||||
|
return filter { subgroup == nil || $0.subgroup == nil || $0.subgroup == subgroup }
|
||||||
|
.map { subgroup == nil ? $0 : DayPlace(place: $0.place, text: $0.text, subgroup: nil) }
|
||||||
|
.filter { seen.insert($0).inserted }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A room label with the lesson's town when the document names one: "ауд. 203Г · Полоцк", or the town alone. */
|
||||||
|
func withPlace(_ room: String?, _ place: String?) -> String? {
|
||||||
|
let parts = [room, place].compactMap { $0 }.filter { !$0.isEmpty }
|
||||||
|
return parts.isEmpty ? nil : parts.joined(separator: " · ")
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Lesson: Codable, Hashable {
|
struct Lesson: Codable, Hashable {
|
||||||
@@ -68,6 +96,8 @@ struct Lesson: Codable, Hashable {
|
|||||||
var teachers: [String] = []
|
var teachers: [String] = []
|
||||||
var rooms: [String] = []
|
var rooms: [String] = []
|
||||||
var roomMoved: Bool = false
|
var roomMoved: Bool = false
|
||||||
|
/** The town, when the cell or the day's note names one: "Полоцк". */
|
||||||
|
var place: String?
|
||||||
var distance: Bool = false
|
var distance: Bool = false
|
||||||
var condition: String = ""
|
var condition: String = ""
|
||||||
var variant: String = "all"
|
var variant: String = "all"
|
||||||
@@ -139,6 +169,7 @@ struct DirectoryLesson: Codable, Hashable {
|
|||||||
var teachers: [String] = []
|
var teachers: [String] = []
|
||||||
var rooms: [String] = []
|
var rooms: [String] = []
|
||||||
var moved: Bool = false
|
var moved: Bool = false
|
||||||
|
var place: String?
|
||||||
var distance: Bool = false
|
var distance: Bool = false
|
||||||
var groups: [String] = []
|
var groups: [String] = []
|
||||||
var condition: String = ""
|
var condition: String = ""
|
||||||
@@ -266,6 +297,7 @@ extension Day {
|
|||||||
weekday = try c.decodeIfPresent(Int.self, forKey: .weekday) ?? 0
|
weekday = try c.decodeIfPresent(Int.self, forKey: .weekday) ?? 0
|
||||||
week = try c.decodeIfPresent(Week.self, forKey: .week)
|
week = try c.decodeIfPresent(Week.self, forKey: .week)
|
||||||
lessons = try c.decodeIfPresent([Lesson].self, forKey: .lessons) ?? []
|
lessons = try c.decodeIfPresent([Lesson].self, forKey: .lessons) ?? []
|
||||||
|
places = try c.decodeIfPresent([DayPlace].self, forKey: .places) ?? []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,6 +306,16 @@ extension WeeklyDay {
|
|||||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
day = try c.decode(Int.self, forKey: .day)
|
day = try c.decode(Int.self, forKey: .day)
|
||||||
lessons = try c.decodeIfPresent([Lesson].self, forKey: .lessons) ?? []
|
lessons = try c.decodeIfPresent([Lesson].self, forKey: .lessons) ?? []
|
||||||
|
places = try c.decodeIfPresent([DayPlace].self, forKey: .places) ?? []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension DayPlace {
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
place = try c.decodeIfPresent(String.self, forKey: .place) ?? ""
|
||||||
|
text = try c.decodeIfPresent(String.self, forKey: .text) ?? ""
|
||||||
|
subgroup = try c.decodeIfPresent(String.self, forKey: .subgroup)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,6 +331,7 @@ extension Lesson {
|
|||||||
teachers = try c.decodeIfPresent([String].self, forKey: .teachers) ?? []
|
teachers = try c.decodeIfPresent([String].self, forKey: .teachers) ?? []
|
||||||
rooms = try c.decodeIfPresent([String].self, forKey: .rooms) ?? []
|
rooms = try c.decodeIfPresent([String].self, forKey: .rooms) ?? []
|
||||||
roomMoved = try c.decodeIfPresent(Bool.self, forKey: .roomMoved) ?? false
|
roomMoved = try c.decodeIfPresent(Bool.self, forKey: .roomMoved) ?? false
|
||||||
|
place = try c.decodeIfPresent(String.self, forKey: .place)
|
||||||
distance = try c.decodeIfPresent(Bool.self, forKey: .distance) ?? false
|
distance = try c.decodeIfPresent(Bool.self, forKey: .distance) ?? false
|
||||||
condition = try c.decodeIfPresent(String.self, forKey: .condition) ?? ""
|
condition = try c.decodeIfPresent(String.self, forKey: .condition) ?? ""
|
||||||
variant = try c.decodeIfPresent(String.self, forKey: .variant) ?? "all"
|
variant = try c.decodeIfPresent(String.self, forKey: .variant) ?? "all"
|
||||||
@@ -375,6 +418,7 @@ extension DirectoryLesson {
|
|||||||
teachers = try c.decodeIfPresent([String].self, forKey: .teachers) ?? []
|
teachers = try c.decodeIfPresent([String].self, forKey: .teachers) ?? []
|
||||||
rooms = try c.decodeIfPresent([String].self, forKey: .rooms) ?? []
|
rooms = try c.decodeIfPresent([String].self, forKey: .rooms) ?? []
|
||||||
moved = try c.decodeIfPresent(Bool.self, forKey: .moved) ?? false
|
moved = try c.decodeIfPresent(Bool.self, forKey: .moved) ?? false
|
||||||
|
place = try c.decodeIfPresent(String.self, forKey: .place)
|
||||||
distance = try c.decodeIfPresent(Bool.self, forKey: .distance) ?? false
|
distance = try c.decodeIfPresent(Bool.self, forKey: .distance) ?? false
|
||||||
groups = try c.decodeIfPresent([String].self, forKey: .groups) ?? []
|
groups = try c.decodeIfPresent([String].self, forKey: .groups) ?? []
|
||||||
condition = try c.decodeIfPresent(String.self, forKey: .condition) ?? ""
|
condition = try c.decodeIfPresent(String.self, forKey: .condition) ?? ""
|
||||||
|
|||||||
@@ -34,6 +34,13 @@ extension Timetable {
|
|||||||
.filter { !hidden.contains($0.hideKey) }
|
.filter { !hidden.contains($0.hideKey) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// "Занятия в Полоцке" on the date for the student's subgroup, only on a day they have lessons.
|
||||||
|
func places(on date: CivilDate, subgroup: String?, hidden: Set<String>) -> [DayPlace] {
|
||||||
|
guard let day = day(date), !day.places.isEmpty,
|
||||||
|
!lessons(on: date, subgroup: subgroup, hidden: hidden).isEmpty else { return [] }
|
||||||
|
return day.places.forSubgroup(subgroup)
|
||||||
|
}
|
||||||
|
|
||||||
func slots(on date: CivilDate, subgroup: String?, hidden: Set<String>) -> [Slot] {
|
func slots(on date: CivilDate, subgroup: String?, hidden: Set<String>) -> [Slot] {
|
||||||
let lessons = lessons(on: date, subgroup: subgroup, hidden: hidden)
|
let lessons = lessons(on: date, subgroup: subgroup, hidden: hidden)
|
||||||
var order: [String] = []
|
var order: [String] = []
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ private func status(_ slot: Slot, day: CivilDate, at date: Date) -> String? {
|
|||||||
|
|
||||||
private func place(_ lesson: Lesson) -> String? {
|
private func place(_ lesson: Lesson) -> String? {
|
||||||
if lesson.distance { return "дист." }
|
if lesson.distance { return "дист." }
|
||||||
return lesson.rooms.isEmpty ? nil : "ауд. " + lesson.rooms.joined(separator: ", ")
|
return withPlace(lesson.rooms.isEmpty ? nil : "ауд. " + lesson.rooms.joined(separator: ", "), lesson.place)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// "Сегодня": the day's lessons ahead, with the current one marked.
|
/// "Сегодня": the day's lessons ahead, with the current one marked.
|
||||||
|
|||||||
@@ -12,16 +12,26 @@ export const lessonLine = (l) =>
|
|||||||
(l.variant === "upper" ? ", верхняя строка" : l.variant === "lower" ? ", нижняя строка" : ""),
|
(l.variant === "upper" ? ", верхняя строка" : l.variant === "lower" ? ", нижняя строка" : ""),
|
||||||
l.subject,
|
l.subject,
|
||||||
l.type,
|
l.type,
|
||||||
l.room,
|
// The town only when the cell names one or the day's note puts the lesson there.
|
||||||
|
[l.room, l.place].filter(Boolean).join(", "),
|
||||||
l.teacher,
|
l.teacher,
|
||||||
[...l.groups, ...l.subgroups.map((s) => `${s.group}/${s.label.replace("Подгруппа ", "п")}`)].join(","),
|
[...l.groups, ...l.subgroups.map((s) => `${s.group}/${s.label.replace("Подгруппа ", "п")}`)].join(","),
|
||||||
]
|
]
|
||||||
.map((part) => String(part ?? "").replace(/\s+/g, " ").trim())
|
.map((part) => String(part ?? "").replace(/\s+/g, " ").trim())
|
||||||
.join(" | ");
|
.join(" | ");
|
||||||
|
|
||||||
|
// "Занятия в Полоцке": a day of a group or subgroup in another town, not a lesson.
|
||||||
|
const placeLine = (p) =>
|
||||||
|
[
|
||||||
|
`@ ${p.date || DAYS[p.day]}`,
|
||||||
|
p.text,
|
||||||
|
[...p.groups, ...p.subgroups.map((s) => `${s.group}/${s.label.replace("Подгруппа ", "п")}`)].join(","),
|
||||||
|
].join(" | ");
|
||||||
|
|
||||||
/** Sorted lesson lines of a parsed document, with its groups and period on top. */
|
/** Sorted lesson lines of a parsed document, with its groups and period on top. */
|
||||||
export const snapshotOf = (schedule) => [
|
export const snapshotOf = (schedule) => [
|
||||||
`# ${schedule.period} · ${schedule.groups.join(", ")}`,
|
`# ${schedule.period} · ${schedule.groups.join(", ")}`,
|
||||||
|
...(schedule.places || []).map(placeLine).sort(),
|
||||||
...schedule.lessons.map(lessonLine).sort(),
|
...schedule.lessons.map(lessonLine).sort(),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -311,6 +311,7 @@ export function dayOf(idx, calendar, { teacher, room }, date) {
|
|||||||
teachers,
|
teachers,
|
||||||
rooms,
|
rooms,
|
||||||
moved: movedOn(entry, date),
|
moved: movedOn(entry, date),
|
||||||
|
place: l.place || null,
|
||||||
distance: entry.distance,
|
distance: entry.distance,
|
||||||
groups: [],
|
groups: [],
|
||||||
documents: [],
|
documents: [],
|
||||||
|
|||||||
@@ -78,8 +78,9 @@ app.use("/api", (_req, res, next) => {
|
|||||||
// 10 — parserVersion in /api/catalog;
|
// 10 — parserVersion in /api/catalog;
|
||||||
// 11 — the catalog is checked by ETag every 5 minutes; `watch`, `telegram` and `catalog.lastCrawl` in /api/health;
|
// 11 — the catalog is checked by ETag every 5 minutes; `watch`, `telegram` and `catalog.lastCrawl` in /api/health;
|
||||||
// 12 — a JSON log line per request;
|
// 12 — a JSON log line per request;
|
||||||
// 13 — the dashboard at /status (password), /api/health only says alive, ready and versions.
|
// 13 — the dashboard at /status (password), /api/health only says alive, ready and versions;
|
||||||
const API_VERSION = 13;
|
// 14 — a lesson's `place` and a day's `places` ("Занятия в Полоцке") in /api/timetable.
|
||||||
|
const API_VERSION = 14;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What the server still waits for before it answers from memory: the catalog, the week table
|
* What the server still waits for before it answers from memory: the catalog, the week table
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ import { join } from "node:path";
|
|||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import { parseWeekRanges, parseTextDates, DATE_LIST, weekday } from "../shared/weeks.js";
|
import { parseWeekRanges, parseTextDates, DATE_LIST, weekday } from "../shared/weeks.js";
|
||||||
import { roomMoves, onlyPlace, weekPlan, partsByWeek } from "./rooms.js";
|
import { roomMoves, onlyPlace, weekPlan, partsByWeek } from "./rooms.js";
|
||||||
|
import { placeNote, lessonPlace, placeOf } from "./places.js";
|
||||||
const run = promisify(execFile);
|
const run = promisify(execFile);
|
||||||
export const PARSER_VERSION = 13;
|
export const PARSER_VERSION = 14;
|
||||||
const days = [
|
const days = [
|
||||||
"понедельник",
|
"понедельник",
|
||||||
"вторник",
|
"вторник",
|
||||||
@@ -66,7 +67,9 @@ function conditionIn(value) {
|
|||||||
);
|
);
|
||||||
return range?.[0].trim() || "";
|
return range?.[0].trim() || "";
|
||||||
}
|
}
|
||||||
export function details(raw) {
|
export function details(text) {
|
||||||
|
// "Новополоцк" on a line of its own, "246(Новополоцк)": the town is no part of the subject or room.
|
||||||
|
const { place, rest: raw } = lessonPlace(text);
|
||||||
const condition = conditionIn(raw);
|
const condition = conditionIn(raw);
|
||||||
let rest = raw.slice(condition.length).trim();
|
let rest = raw.slice(condition.length).trim();
|
||||||
// "Экзамен. Финансы …" or "Зачёт: …": the label is the kind of session event, not the subject.
|
// "Экзамен. Финансы …" or "Зачёт: …": the label is the kind of session event, not the subject.
|
||||||
@@ -106,7 +109,7 @@ export function details(raw) {
|
|||||||
// A long note under a capitalised heading ("ВВЕДЕНИЕ В ВЫСШЕЕ ОБРАЗОВАНИЕ / г. Полоцк, …"):
|
// A long note under a capitalised heading ("ВВЕДЕНИЕ В ВЫСШЕЕ ОБРАЗОВАНИЕ / г. Полоцк, …"):
|
||||||
// the heading names it; the whole text stays in the details.
|
// the heading names it; the whole text stays in the details.
|
||||||
const heading = rest.split("\n")[0].trim();
|
const heading = rest.split("\n")[0].trim();
|
||||||
if (subject === rest && rest.split("\n").length >= 3 && /[А-ЯЁ]{4}/u.test(heading) && !/[а-яё]/u.test(heading)) subject = heading;
|
if (subject === rest && text.split("\n").length >= 3 && /[А-ЯЁ]{4}/u.test(heading) && !/[а-яё]/u.test(heading)) subject = heading;
|
||||||
const defence = /^защита/i.test(leading?.[1] || "");
|
const defence = /^защита/i.test(leading?.[1] || "");
|
||||||
const token =
|
const token =
|
||||||
kind?.[1].replace(/[.\s/]/g, "").toLowerCase() ||
|
kind?.[1].replace(/[.\s/]/g, "").toLowerCase() ||
|
||||||
@@ -148,6 +151,7 @@ export function details(raw) {
|
|||||||
: "Занятие",
|
: "Занятие",
|
||||||
condition,
|
condition,
|
||||||
...(own && { ownSubgroup: Number(own[1]) }),
|
...(own && { ownSubgroup: Number(own[1]) }),
|
||||||
|
...(place && { place }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
// The kind of a lesson in brackets: "(л.)", "(п.з.)", "(уст.л.)", "(конс.)".
|
// The kind of a lesson in brackets: "(л.)", "(п.з.)", "(уст.л.)", "(конс.)".
|
||||||
@@ -394,6 +398,8 @@ const isoDate = (m) => `${m[3]}-${m[2].padStart(2, "0")}-${m[1].padStart(2, "0")
|
|||||||
export function parseDocx(buffer, source = {}) {
|
export function parseDocx(buffer, source = {}) {
|
||||||
const document = readDocx(buffer);
|
const document = readDocx(buffer);
|
||||||
const lessons = [],
|
const lessons = [],
|
||||||
|
// "Занятия в Полоцке": the days a group, or one of its subgroups, spends in another town.
|
||||||
|
places = [],
|
||||||
groups = new Set(),
|
groups = new Set(),
|
||||||
warnings = new Set();
|
warnings = new Set();
|
||||||
// The catalog folder ("2026–2027") wins: some files keep last year's header text.
|
// The catalog folder ("2026–2027") wins: some files keep last year's header text.
|
||||||
@@ -548,6 +554,28 @@ export function parseDocx(buffer, source = {}) {
|
|||||||
const spansBoth =
|
const spansBoth =
|
||||||
cell.merge === "restart" &&
|
cell.merge === "restart" &&
|
||||||
rows[i + 1]?.find((c) => c.col === cell.col)?.merge === "continue";
|
rows[i + 1]?.find((c) => c.col === cell.col)?.merge === "continue";
|
||||||
|
const columnSubgroups = sessionColumns
|
||||||
|
? []
|
||||||
|
: ranges
|
||||||
|
.filter(
|
||||||
|
(r) =>
|
||||||
|
targetGroups.includes(r.group) &&
|
||||||
|
cell.col >= r.col &&
|
||||||
|
cell.col + cell.span <= r.end &&
|
||||||
|
cell.span < r.end - r.col,
|
||||||
|
)
|
||||||
|
.map((r) => ({
|
||||||
|
group: r.group,
|
||||||
|
label: `Подгруппа ${cell.col - r.col + 1}`,
|
||||||
|
}));
|
||||||
|
// "Занятия в Полоцке" over the day's first row is no lesson: the column's lessons that day
|
||||||
|
// are in that town. A lesson may follow it in the same cell.
|
||||||
|
const note = placeNote(raw);
|
||||||
|
if (note) {
|
||||||
|
places.push({ day, date, groups: targetGroups, subgroups: columnSubgroups, place: note.place, text: note.text });
|
||||||
|
raw = note.rest;
|
||||||
|
if (!raw.trim()) return;
|
||||||
|
}
|
||||||
for (const { text: whole, main: alternative, plan } of entries(raw)) {
|
for (const { text: whole, main: alternative, plan } of entries(raw)) {
|
||||||
// "Единый день информирования 10.09;15.10;19.11;17.12": the dates are not part of the
|
// "Единый день информирования 10.09;15.10;19.11;17.12": the dates are not part of the
|
||||||
// subject, nor is a move to another room on a date ("412н 26.10.2026-333").
|
// subject, nor is a move to another room on a date ("412н 26.10.2026-333").
|
||||||
@@ -571,20 +599,7 @@ export function parseDocx(buffer, source = {}) {
|
|||||||
data = read(alternative.slice(lead.length).replace(/^[\s–—:-]+/, ""));
|
data = read(alternative.slice(lead.length).replace(/^[\s–—:-]+/, ""));
|
||||||
if (oneDate) data.condition = lead.text;
|
if (oneDate) data.condition = lead.text;
|
||||||
}
|
}
|
||||||
const subgroups = sessionColumns
|
const subgroups = [...columnSubgroups];
|
||||||
? []
|
|
||||||
: ranges
|
|
||||||
.filter(
|
|
||||||
(r) =>
|
|
||||||
targetGroups.includes(r.group) &&
|
|
||||||
cell.col >= r.col &&
|
|
||||||
cell.col + cell.span <= r.end &&
|
|
||||||
cell.span < r.end - r.col,
|
|
||||||
)
|
|
||||||
.map((r) => ({
|
|
||||||
group: r.group,
|
|
||||||
label: `Подгруппа ${cell.col - r.col + 1}`,
|
|
||||||
}));
|
|
||||||
// A subgroup named in the cell ("2п/гр.") when the column does not tell one.
|
// A subgroup named in the cell ("2п/гр.") when the column does not tell one.
|
||||||
const own = data.ownSubgroup;
|
const own = data.ownSubgroup;
|
||||||
delete data.ownSubgroup;
|
delete data.ownSubgroup;
|
||||||
@@ -677,12 +692,22 @@ export function parseDocx(buffer, source = {}) {
|
|||||||
warnings.add(
|
warnings.add(
|
||||||
"Номера подгрупп обозначают столбцы внутри группы в исходном Word.",
|
"Номера подгрупп обозначают столбцы внутри группы в исходном Word.",
|
||||||
);
|
);
|
||||||
|
// A lesson on a day the group spends in another town is there too, unless its cell says otherwise.
|
||||||
|
const labels = new Map();
|
||||||
|
for (const l of lessons)
|
||||||
|
for (const s of l.subgroups) labels.set(s.group, new Set([...(labels.get(s.group) || []), s.label]));
|
||||||
|
for (const lesson of lessons) {
|
||||||
|
if (lesson.place || !places.length) continue;
|
||||||
|
const place = placeOf(lesson, places, (group) => [...(labels.get(group) || [])]);
|
||||||
|
if (place) lesson.place = place;
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
id: createHash("sha256").update(buffer).update(source.fileId || source.name || "").digest("hex").slice(0, 20),
|
id: createHash("sha256").update(buffer).update(source.fileId || source.name || "").digest("hex").slice(0, 20),
|
||||||
groups: [...groups],
|
groups: [...groups],
|
||||||
lessons: lessons.sort(
|
lessons: lessons.sort(
|
||||||
(a, b) => a.day - b.day || a.start.localeCompare(b.start),
|
(a, b) => a.day - b.day || a.start.localeCompare(b.start),
|
||||||
),
|
),
|
||||||
|
...(places.length && { places }),
|
||||||
notes: document.notes,
|
notes: document.notes,
|
||||||
warnings: [...warnings],
|
warnings: [...warnings],
|
||||||
source: {
|
source: {
|
||||||
|
|||||||
83
server/places.js
Normal file
83
server/places.js
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
// Towns of a lesson, as Word cells name them. Most lessons are in Novopolotsk; some faculties
|
||||||
|
// teach in Polotsk, and a cell says when a lesson or a whole day is in the other town:
|
||||||
|
// "Занятия в Полоцке" over a day's first row (that day, the column's lessons are in Polotsk),
|
||||||
|
// "Новополоцк" or "г.Новополоцк" on a line of its own, "246(Новополоцк)" after a room.
|
||||||
|
|
||||||
|
// Towns a cell may name without "г.": a bare capitalised word is otherwise a subject.
|
||||||
|
const TOWNS = "Новополоцк|Полоцк|Междуречье|Витебск|Минск";
|
||||||
|
const TOWN = String.raw`(?:г\.\s*([А-ЯЁ][а-яё]+(?:-[А-ЯЁ][а-яё]+)?)|(${TOWNS})е?)`;
|
||||||
|
// "Занятия в Полоцке", "Занятия проходят в г. Новополоцк": the rest of the line, if any, is a lesson.
|
||||||
|
const NOTE = new RegExp(
|
||||||
|
String.raw`^[Зз]анятия\s+(?:(?:проход[яи]т|будут\s+проходить)\s+)?в\s+(?:г\.\s*)?([А-ЯЁ][а-яё]+(?:-[А-ЯЁ][а-яё]+)?)(?=$|[\s.,;:!–—-])[.!]?`,
|
||||||
|
"u",
|
||||||
|
);
|
||||||
|
// A line that starts with the town: "г.Новополоцк", or "г. Полоцк, Дом офицеров" with the venue.
|
||||||
|
const LINE = new RegExp(String.raw`^\(?${TOWN}\)?(?:[.,]?$|(,\s*\S.*))`, "u");
|
||||||
|
// "246(Новополоцк)", "121 (г.Новополоцк)".
|
||||||
|
const AFTER_ROOM = new RegExp(String.raw`(?<=\d[А-ЯЁа-яё]{0,2})\s*\(${TOWN}\)`, "u");
|
||||||
|
|
||||||
|
/** "Полоцке" → "Полоцк", "Витебске" → "Витебск"; "Междуречье" stays. */
|
||||||
|
export const townName = (word) => word.replace(/(?<=(?:к|ст))е$/u, "");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A note that a day is in another town, on a line of the cell: `{text, place, rest}`, the
|
||||||
|
* cell without the note in `rest`; null when the cell has none.
|
||||||
|
*/
|
||||||
|
export function placeNote(text) {
|
||||||
|
const lines = (text || "").split("\n");
|
||||||
|
for (const [i, line] of lines.entries()) {
|
||||||
|
const m = line.trim().match(NOTE);
|
||||||
|
if (!m) continue;
|
||||||
|
const rest = [...lines.slice(0, i), line.trim().slice(m[0].length).trim(), ...lines.slice(i + 1)]
|
||||||
|
.filter((l) => l.trim())
|
||||||
|
.join("\n");
|
||||||
|
return { text: m[0].replace(/[.!]$/, "").replace(/\s+/g, " "), place: townName(m[1]), rest };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The town a lesson's own text names, and the text without it: `{place, rest}`. */
|
||||||
|
export function lessonPlace(text) {
|
||||||
|
let place = null;
|
||||||
|
const rest = (text || "")
|
||||||
|
.split("\n")
|
||||||
|
.filter((line) => {
|
||||||
|
const m = line.trim().match(LINE);
|
||||||
|
if (m && !place) place = townName(m[1] || m[2]);
|
||||||
|
// The town alone goes; a venue after it stays in the text.
|
||||||
|
return !m || m[3];
|
||||||
|
})
|
||||||
|
.join("\n")
|
||||||
|
.replace(AFTER_ROOM, (_, named, town) => {
|
||||||
|
place ||= townName(named || town);
|
||||||
|
return "";
|
||||||
|
});
|
||||||
|
return { place, rest };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The town of a lesson from the notes of its day: for every group of the lesson, a note for its
|
||||||
|
* subgroup or the whole group, or — for a lesson of the whole group — notes in every subgroup
|
||||||
|
* that name the same town. `labelsOf(group)`: the group's subgroup labels.
|
||||||
|
*/
|
||||||
|
export function placeOf(lesson, places, labelsOf) {
|
||||||
|
let found = null;
|
||||||
|
for (const group of lesson.groups) {
|
||||||
|
const notes = places.filter(
|
||||||
|
(p) => p.day === lesson.day && (!p.date || p.date === lesson.date) && p.groups.includes(group),
|
||||||
|
);
|
||||||
|
if (!notes.length) return null;
|
||||||
|
const label = lesson.subgroups.find((s) => s.group === group)?.label;
|
||||||
|
const own = (p) => p.subgroups.filter((s) => s.group === group).map((s) => s.label);
|
||||||
|
let place = notes.find((p) => !own(p).length || (label && own(p).includes(label)))?.place;
|
||||||
|
if (!place && !label) {
|
||||||
|
const towns = new Map(notes.flatMap((p) => own(p).map((l) => [l, p.place])));
|
||||||
|
const labels = labelsOf(group);
|
||||||
|
if (labels.length && labels.every((l) => towns.has(l)) && new Set(towns.values()).size === 1)
|
||||||
|
place = towns.get(labels[0]);
|
||||||
|
}
|
||||||
|
if (!place || (found && found !== place)) return null;
|
||||||
|
found = place;
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
// third-party clients use it instead of re-implementing the white/green week rules.
|
// third-party clients use it instead of re-implementing the white/green week rules.
|
||||||
import { semesterView, addDays, weekday, weekIndexOf } from "../shared/weeks.js";
|
import { semesterView, addDays, weekday, weekIndexOf } from "../shared/weeks.js";
|
||||||
import { lessonKey, subgroupsOf } from "../shared/lessons.js";
|
import { lessonKey, subgroupsOf } from "../shared/lessons.js";
|
||||||
|
import { placesOn } from "../shared/places.js";
|
||||||
import { occurrence, peopleAndRooms, roomsOn, teachersOn, movedOn } from "./directory.js";
|
import { occurrence, peopleAndRooms, roomsOn, teachersOn, movedOn } from "./directory.js";
|
||||||
import { apiError } from "./errors.js";
|
import { apiError } from "./errors.js";
|
||||||
|
|
||||||
@@ -24,6 +25,8 @@ function lessonOut(lesson, group, people, date, status, calendar, week = null) {
|
|||||||
teachers: date ? teachersOn(people, week) : people.teachers,
|
teachers: date ? teachersOn(people, week) : people.teachers,
|
||||||
rooms,
|
rooms,
|
||||||
roomMoved: Boolean(date && movedOn(people, date)),
|
roomMoved: Boolean(date && movedOn(people, date)),
|
||||||
|
// "Полоцк": the town, when the cell or the day's note names one.
|
||||||
|
place: lesson.place || null,
|
||||||
distance: people.distance,
|
distance: people.distance,
|
||||||
condition: lesson.condition,
|
condition: lesson.condition,
|
||||||
variant: lesson.variant,
|
variant: lesson.variant,
|
||||||
@@ -58,6 +61,7 @@ export function timetable({ file, schedule, calendar, group, from, days, range,
|
|||||||
}
|
}
|
||||||
let count = Math.min(Math.max(Number(days) || 7, 1), MAX_DAYS);
|
let count = Math.min(Math.max(Number(days) || 7, 1), MAX_DAYS);
|
||||||
const lessons = schedule.lessons.filter((l) => l.groups.includes(group));
|
const lessons = schedule.lessons.filter((l) => l.groups.includes(group));
|
||||||
|
const labels = subgroupsOf(schedule, group);
|
||||||
// A session schedule printed with a date on every day needs no week table: its own dates
|
// A session schedule printed with a date on every day needs no week table: its own dates
|
||||||
// say when each lesson takes place, even in a year the table no longer covers.
|
// say when each lesson takes place, even in a year the table no longer covers.
|
||||||
const sessionDates = lessons.every((l) => l.date || l.dates?.length)
|
const sessionDates = lessons.every((l) => l.date || l.dates?.length)
|
||||||
@@ -112,11 +116,14 @@ export function timetable({ file, schedule, calendar, group, from, days, range,
|
|||||||
if (entry.lesson.date === date || entry.lesson.dates?.includes(date))
|
if (entry.lesson.date === date || entry.lesson.dates?.includes(date))
|
||||||
dayLessons.push(lessonOut(entry.lesson, group, entry, date, "yes", calendar));
|
dayLessons.push(lessonOut(entry.lesson, group, entry, date, "yes", calendar));
|
||||||
dayLessons.sort((a, b) => a.start.localeCompare(b.start));
|
dayLessons.sort((a, b) => a.start.localeCompare(b.start));
|
||||||
|
// A day without lessons (a holiday, weeks of practice) is in no town.
|
||||||
|
const places = dayLessons.length ? placesOn(schedule.places, group, labels, weekday(date), date) : [];
|
||||||
out.push({
|
out.push({
|
||||||
date,
|
date,
|
||||||
weekday: weekday(date),
|
weekday: weekday(date),
|
||||||
week: week ? { n: week.n, color: week.color } : null,
|
week: week ? { n: week.n, color: week.color } : null,
|
||||||
lessons: dayLessons,
|
lessons: dayLessons,
|
||||||
|
...(places.length && { places }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const weekly = [1, 2, 3, 4, 5, 6, 7]
|
const weekly = [1, 2, 3, 4, 5, 6, 7]
|
||||||
@@ -127,7 +134,11 @@ export function timetable({ file, schedule, calendar, group, from, days, range,
|
|||||||
.map((e) => lessonOut(e.lesson, group, e, null, "yes", calendar))
|
.map((e) => lessonOut(e.lesson, group, e, null, "yes", calendar))
|
||||||
.sort((a, b) => a.start.localeCompare(b.start)),
|
.sort((a, b) => a.start.localeCompare(b.start)),
|
||||||
}))
|
}))
|
||||||
.filter((d) => d.lessons.length);
|
.filter((d) => d.lessons.length)
|
||||||
|
.map((d) => {
|
||||||
|
const places = placesOn(schedule.places, group, labels, d.day);
|
||||||
|
return places.length ? { ...d, places } : d;
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
group,
|
group,
|
||||||
document: {
|
document: {
|
||||||
|
|||||||
1
shared/lessons.d.ts
vendored
1
shared/lessons.d.ts
vendored
@@ -6,6 +6,7 @@ interface LessonLike {
|
|||||||
subject: string;
|
subject: string;
|
||||||
teacher: string;
|
teacher: string;
|
||||||
room: string;
|
room: string;
|
||||||
|
place?: string;
|
||||||
type: string;
|
type: string;
|
||||||
condition: string;
|
condition: string;
|
||||||
variant: "all" | "upper" | "lower";
|
variant: "all" | "upper" | "lower";
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ const FIELDS = [
|
|||||||
["day", "День"],
|
["day", "День"],
|
||||||
["date", "Дата"],
|
["date", "Дата"],
|
||||||
["room", "Аудитория"],
|
["room", "Аудитория"],
|
||||||
|
// "Полоцк": the town a cell or the day's note names (parser 14).
|
||||||
|
["place", "Город"],
|
||||||
["teacher", "Преподаватель"],
|
["teacher", "Преподаватель"],
|
||||||
["condition", "Недели"],
|
["condition", "Недели"],
|
||||||
["type", "Вид занятия"],
|
["type", "Вид занятия"],
|
||||||
|
|||||||
23
shared/places.d.ts
vendored
Normal file
23
shared/places.d.ts
vendored
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
/** "Занятия в Полоцке" as the parser keeps it: a day of a group, or of some of its subgroups. */
|
||||||
|
export interface PlaceNote {
|
||||||
|
day: number;
|
||||||
|
date: string | null;
|
||||||
|
groups: string[];
|
||||||
|
subgroups: { group: string; label: string }[];
|
||||||
|
place: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
/** A note of one day for one group; `subgroup` null for the whole group. */
|
||||||
|
export interface DayPlace {
|
||||||
|
place: string;
|
||||||
|
text: string;
|
||||||
|
subgroup: string | null;
|
||||||
|
}
|
||||||
|
export function placesOn(
|
||||||
|
places: PlaceNote[] | null | undefined,
|
||||||
|
group: string,
|
||||||
|
labels: string[],
|
||||||
|
day: number,
|
||||||
|
date?: string | null,
|
||||||
|
): DayPlace[];
|
||||||
|
export function placesFor(list: DayPlace[], subgroup: string | null | undefined): DayPlace[];
|
||||||
34
shared/places.js
Normal file
34
shared/places.js
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
// "Занятия в Полоцке": Word marks the days a group spends in another town over the day's first
|
||||||
|
// row; the parser keeps them as notes of the day (server/places.js). The notes of a day for one
|
||||||
|
// group, shared by the server's timetable and the browser.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The notes on a weekday (or a date) of the group: `{place, text, subgroup}`, the subgroup null
|
||||||
|
* for the whole group. The same note in every subgroup (`labels`) is the whole group's.
|
||||||
|
*/
|
||||||
|
export function placesOn(places, group, labels, day, date = null) {
|
||||||
|
const on = (places || [])
|
||||||
|
.filter((p) => p.groups.includes(group) && p.day === day && (!p.date || p.date === date))
|
||||||
|
.map((p) => ({ place: p.place, text: p.text, subgroup: p.subgroups.find((s) => s.group === group)?.label || null }));
|
||||||
|
const whole = on.filter((p) => !p.subgroup);
|
||||||
|
for (const p of on)
|
||||||
|
if (
|
||||||
|
p.subgroup &&
|
||||||
|
!whole.some((w) => w.text === p.text) &&
|
||||||
|
labels.length &&
|
||||||
|
labels.every((label) => on.some((o) => o.subgroup === label && o.text === p.text))
|
||||||
|
)
|
||||||
|
whole.push({ ...p, subgroup: null });
|
||||||
|
const parts = on.filter((p) => p.subgroup && !whole.some((w) => w.text === p.text));
|
||||||
|
return [...whole, ...parts].filter(
|
||||||
|
(p, i, all) => all.findIndex((o) => o.text === p.text && o.subgroup === p.subgroup) === i,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The notes for the student's subgroup; a note for their own subgroup reads as the whole day's. */
|
||||||
|
export function placesFor(list, subgroup) {
|
||||||
|
return list
|
||||||
|
.filter((p) => !subgroup || !p.subgroup || p.subgroup === subgroup)
|
||||||
|
.map((p) => (subgroup ? { ...p, subgroup: null } : p))
|
||||||
|
.filter((p, i, all) => all.findIndex((o) => o.text === p.text && o.subgroup === p.subgroup) === i);
|
||||||
|
}
|
||||||
54
src/App.tsx
54
src/App.tsx
@@ -60,6 +60,7 @@ import {
|
|||||||
personalFilter,
|
personalFilter,
|
||||||
subgroupsOf,
|
subgroupsOf,
|
||||||
} from "../shared/lessons";
|
} from "../shared/lessons";
|
||||||
|
import { placesFor, placesOn, type DayPlace } from "../shared/places";
|
||||||
import qrcode from "qrcode-generator";
|
import qrcode from "qrcode-generator";
|
||||||
const SOURCE =
|
const SOURCE =
|
||||||
"https://nextcloud.psu.by/index.php/s/no5zKbMrns6j5jQ?dir=/2026-2027";
|
"https://nextcloud.psu.by/index.php/s/no5zKbMrns6j5jQ?dir=/2026-2027";
|
||||||
@@ -197,10 +198,10 @@ function LessonCard({
|
|||||||
{lesson.teacher}
|
{lesson.teacher}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{lesson.room && (
|
{(lesson.room || lesson.place) && (
|
||||||
<span>
|
<span>
|
||||||
<Icon name="location_on" size={20} />
|
<Icon name="location_on" size={20} />
|
||||||
Ауд. {lesson.room}
|
{whereLabel(lesson, "Ауд.")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{subgroup && (
|
{subgroup && (
|
||||||
@@ -248,6 +249,29 @@ function LessonCard({
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
/** "Ауд. 249Ж · Полоцк": the room with the town the document names, or the town alone. */
|
||||||
|
const whereLabel = (l: Lesson, prefix: string) =>
|
||||||
|
[l.room && `${prefix} ${l.room}`, l.place].filter(Boolean).join(" · ");
|
||||||
|
/**
|
||||||
|
* "Занятия в Полоцке" over the day's lessons: Word marks the days a group spends in another
|
||||||
|
* town, and every lesson of that day is there.
|
||||||
|
*/
|
||||||
|
function DayPlaces({ places }: { places: DayPlace[] }) {
|
||||||
|
if (!places.length) return null;
|
||||||
|
return (
|
||||||
|
<div className="day-places" role="note">
|
||||||
|
{places.map((p) => (
|
||||||
|
<p key={`${p.text}|${p.subgroup}`}>
|
||||||
|
<Icon name="location_city" />
|
||||||
|
<span>
|
||||||
|
<strong>{p.text}</strong>
|
||||||
|
<small>{p.subgroup ? `${p.subgroup} · пометка в Word на этот день` : "Пометка в Word на этот день"}</small>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
function ScheduleList({
|
function ScheduleList({
|
||||||
lessons,
|
lessons,
|
||||||
ctx,
|
ctx,
|
||||||
@@ -434,7 +458,7 @@ function ChangesList({ record }: { record: ChangeRecord }) {
|
|||||||
<small>{whenLabel(l)}</small>
|
<small>{whenLabel(l)}</small>
|
||||||
<b>{l.subject}</b>
|
<b>{l.subject}</b>
|
||||||
<span className="change-field">
|
<span className="change-field">
|
||||||
{[l.type, l.teacher, l.room && `ауд. ${l.room}`, l.condition]
|
{[l.type, l.teacher, whereLabel(l, "ауд."), l.condition]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" · ")}
|
.join(" · ")}
|
||||||
</span>
|
</span>
|
||||||
@@ -452,7 +476,7 @@ function ChangesList({ record }: { record: ChangeRecord }) {
|
|||||||
<small>{whenLabel(l)}</small>
|
<small>{whenLabel(l)}</small>
|
||||||
<b>{l.subject}</b>
|
<b>{l.subject}</b>
|
||||||
<span className="change-field">
|
<span className="change-field">
|
||||||
{[l.type, l.teacher, l.room && `ауд. ${l.room}`, l.condition]
|
{[l.type, l.teacher, whereLabel(l, "ауд."), l.condition]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" · ")}
|
.join(" · ")}
|
||||||
</span>
|
</span>
|
||||||
@@ -998,6 +1022,15 @@ export default function App() {
|
|||||||
return list.filter((l) => l.day === d && occursOn(l, iso) !== false);
|
return list.filter((l) => l.day === d && occursOn(l, iso) !== false);
|
||||||
};
|
};
|
||||||
const dayLessons = lessonsOn(visible, day);
|
const dayLessons = lessonsOn(visible, day);
|
||||||
|
const subgroupLabels = schedule ? subgroupsOf(schedule, group) : [];
|
||||||
|
/** "Занятия в Полоцке" for the shown lessons of a weekday; none on a day without them. */
|
||||||
|
const placesOfDay = (d: number, lessons: Lesson[]) =>
|
||||||
|
lessons.length
|
||||||
|
? placesFor(
|
||||||
|
placesOn(schedule?.places, group, subgroupLabels, d, calendarMode ? dateInWeek(week!, d) : null),
|
||||||
|
prefs?.subgroup,
|
||||||
|
)
|
||||||
|
: [];
|
||||||
const hiddenToday = lessonsOn(hiddenLessons, day).length;
|
const hiddenToday = lessonsOn(hiddenLessons, day).length;
|
||||||
const hiddenThisWeek = DAYS.reduce(
|
const hiddenThisWeek = DAYS.reduce(
|
||||||
(sum, _, i) => sum + lessonsOn(hiddenLessons, i + 1).length,
|
(sum, _, i) => sum + lessonsOn(hiddenLessons, i + 1).length,
|
||||||
@@ -1638,10 +1671,13 @@ export default function App() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{dayLessons.length ? (
|
{dayLessons.length ? (
|
||||||
|
<>
|
||||||
|
<DayPlaces places={placesOfDay(day, dayLessons)} />
|
||||||
<ScheduleList
|
<ScheduleList
|
||||||
lessons={dayLessons}
|
lessons={dayLessons}
|
||||||
ctx={listContext(dayIsToday)}
|
ctx={listContext(dayIsToday)}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="empty-state">
|
<div className="empty-state">
|
||||||
<Icon
|
<Icon
|
||||||
@@ -1714,6 +1750,7 @@ export default function App() {
|
|||||||
{name}
|
{name}
|
||||||
{iso && <small>{longDate(iso)}</small>}
|
{iso && <small>{longDate(iso)}</small>}
|
||||||
</h2>
|
</h2>
|
||||||
|
<DayPlaces places={placesOfDay(d, lessons)} />
|
||||||
<ScheduleList
|
<ScheduleList
|
||||||
lessons={lessons}
|
lessons={lessons}
|
||||||
ctx={listContext(
|
ctx={listContext(
|
||||||
@@ -2091,7 +2128,7 @@ export default function App() {
|
|||||||
</small>
|
</small>
|
||||||
<b>{l.subject}</b>
|
<b>{l.subject}</b>
|
||||||
<span>
|
<span>
|
||||||
{l.condition} {l.teacher} {l.room && `· ауд. ${l.room}`}
|
{l.condition} {l.teacher} {(l.room || l.place) && `· ${whereLabel(l, "ауд.")}`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -2169,6 +2206,13 @@ export default function App() {
|
|||||||
<small>Аудитория</small>
|
<small>Аудитория</small>
|
||||||
<b>{details.room || "Не указана"}</b>
|
<b>{details.room || "Не указана"}</b>
|
||||||
</span>
|
</span>
|
||||||
|
{details.place && (
|
||||||
|
<span>
|
||||||
|
<Icon name="location_city" size={20} />
|
||||||
|
<small>Город</small>
|
||||||
|
<b>{details.place}</b>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{calendar && detailDates && !details.date && hasWeekLimit(details) && (
|
{calendar && detailDates && !details.date && hasWeekLimit(details) && (
|
||||||
<section className="detail-weeks" aria-label="Недели занятия">
|
<section className="detail-weeks" aria-label="Недели занятия">
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ interface DayLesson {
|
|||||||
teachers: string[];
|
teachers: string[];
|
||||||
rooms: string[];
|
rooms: string[];
|
||||||
moved: boolean;
|
moved: boolean;
|
||||||
|
/** "Полоцк", when the document names the town (API 14). */
|
||||||
|
place?: string | null;
|
||||||
distance: boolean;
|
distance: boolean;
|
||||||
groups: string[];
|
groups: string[];
|
||||||
documents: { id: string; name: string }[];
|
documents: { id: string; name: string }[];
|
||||||
@@ -460,10 +462,10 @@ export default function Search({
|
|||||||
{l.groups.join(", ")}
|
{l.groups.join(", ")}
|
||||||
</span>
|
</span>
|
||||||
{target.kind === "teacher" ? (
|
{target.kind === "teacher" ? (
|
||||||
l.rooms.length > 0 && (
|
(l.rooms.length > 0 || l.place) && (
|
||||||
<span>
|
<span>
|
||||||
<Icon name="location_on" size={20} />
|
<Icon name="location_on" size={20} />
|
||||||
Ауд. {l.rooms.join(", ")}
|
{[l.rooms.length > 0 && `Ауд. ${l.rooms.join(", ")}`, l.place].filter(Boolean).join(" · ")}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import Groups from "@material-symbols/svg-400/rounded/groups.svg?react";
|
|||||||
import GroupsFill from "@material-symbols/svg-400/rounded/groups-fill.svg?react";
|
import GroupsFill from "@material-symbols/svg-400/rounded/groups-fill.svg?react";
|
||||||
import Info from "@material-symbols/svg-400/rounded/info.svg?react";
|
import Info from "@material-symbols/svg-400/rounded/info.svg?react";
|
||||||
import KeyboardArrowDown from "@material-symbols/svg-400/rounded/keyboard_arrow_down.svg?react";
|
import KeyboardArrowDown from "@material-symbols/svg-400/rounded/keyboard_arrow_down.svg?react";
|
||||||
|
import LocationCity from "@material-symbols/svg-400/rounded/location_city.svg?react";
|
||||||
import LocationOn from "@material-symbols/svg-400/rounded/location_on.svg?react";
|
import LocationOn from "@material-symbols/svg-400/rounded/location_on.svg?react";
|
||||||
import MobileArrowDown from "@material-symbols/svg-400/rounded/mobile_arrow_down.svg?react";
|
import MobileArrowDown from "@material-symbols/svg-400/rounded/mobile_arrow_down.svg?react";
|
||||||
import MoreHoriz from "@material-symbols/svg-400/rounded/more_horiz.svg?react";
|
import MoreHoriz from "@material-symbols/svg-400/rounded/more_horiz.svg?react";
|
||||||
@@ -66,6 +67,7 @@ const icons = {
|
|||||||
groups: Groups,
|
groups: Groups,
|
||||||
info: Info,
|
info: Info,
|
||||||
keyboard_arrow_down: KeyboardArrowDown,
|
keyboard_arrow_down: KeyboardArrowDown,
|
||||||
|
location_city: LocationCity,
|
||||||
location_on: LocationOn,
|
location_on: LocationOn,
|
||||||
mobile_arrow_down: MobileArrowDown,
|
mobile_arrow_down: MobileArrowDown,
|
||||||
more_horiz: MoreHoriz,
|
more_horiz: MoreHoriz,
|
||||||
|
|||||||
@@ -610,6 +610,39 @@ a {
|
|||||||
display: grid;
|
display: grid;
|
||||||
gap: 32px;
|
gap: 32px;
|
||||||
}
|
}
|
||||||
|
/* "Занятия в Полоцке": the day's town above its lessons, tonal like the update banners. */
|
||||||
|
.day-places {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-radius: var(--md-sys-shape-corner-large-increased);
|
||||||
|
background: var(--md-sys-color-tertiary-container);
|
||||||
|
color: var(--md-sys-color-on-tertiary-container);
|
||||||
|
}
|
||||||
|
.day-places p {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.day-places span {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.day-places strong {
|
||||||
|
font-size: var(--md-sys-typescale-title-medium-size);
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
.day-places small {
|
||||||
|
font-size: var(--md-sys-typescale-body-medium-size);
|
||||||
|
}
|
||||||
|
.week-day > .day-places {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
.day-pane[data-slide] {
|
.day-pane[data-slide] {
|
||||||
animation: day-enter var(--md-sys-motion-duration-medium)
|
animation: day-enter var(--md-sys-motion-duration-medium)
|
||||||
var(--md-sys-motion-easing-emphasized-decelerate);
|
var(--md-sys-motion-easing-emphasized-decelerate);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { WeekRange } from "../shared/weeks";
|
import type { WeekRange } from "../shared/weeks";
|
||||||
|
import type { PlaceNote } from "../shared/places";
|
||||||
export interface Lesson {
|
export interface Lesson {
|
||||||
id: string;
|
id: string;
|
||||||
day: number;
|
day: number;
|
||||||
@@ -9,6 +10,8 @@ export interface Lesson {
|
|||||||
subject: string;
|
subject: string;
|
||||||
teacher: string;
|
teacher: string;
|
||||||
room: string;
|
room: string;
|
||||||
|
/** "Полоцк": the town, when the cell or the day's note names one (parser 14). */
|
||||||
|
place?: string;
|
||||||
type: string;
|
type: string;
|
||||||
condition: string;
|
condition: string;
|
||||||
groups: string[];
|
groups: string[];
|
||||||
@@ -24,6 +27,8 @@ export interface Schedule {
|
|||||||
id: string;
|
id: string;
|
||||||
groups: string[];
|
groups: string[];
|
||||||
lessons: Lesson[];
|
lessons: Lesson[];
|
||||||
|
/** "Занятия в Полоцке": days of a group or subgroup in another town, not lessons. */
|
||||||
|
places?: PlaceNote[];
|
||||||
notes: string[];
|
notes: string[];
|
||||||
warnings: string[];
|
warnings: string[];
|
||||||
source: {
|
source: {
|
||||||
|
|||||||
144
test/places.test.js
Normal file
144
test/places.test.js
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import AdmZip from "adm-zip";
|
||||||
|
import { parseDocx } from "../server/parser.js";
|
||||||
|
import { placeNote, lessonPlace } from "../server/places.js";
|
||||||
|
import { placesOn, placesFor } from "../shared/places.js";
|
||||||
|
import { parseWeeksDocx, parseBellsDocx } from "../server/calendar.js";
|
||||||
|
import { mergeWeekTables } from "../server/source.js";
|
||||||
|
import { timetable } from "../server/timetable.js";
|
||||||
|
|
||||||
|
// "Занятия в Полоцке" over a day's first row, as 24-ИЯ (РА)и-4…6 write it: no lesson, but the
|
||||||
|
// town of that day's lessons in the column.
|
||||||
|
const fixture = (name) => readFileSync(new URL(`./fixtures/${name}.docx`, import.meta.url));
|
||||||
|
const calendar = {
|
||||||
|
...mergeWeekTables([parseWeeksDocx(fixture("weeks"))]),
|
||||||
|
bells: parseBellsDocx(fixture("bells")),
|
||||||
|
source: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const escape = (s) => s.replace(/&/g, "&").replace(/</g, "<");
|
||||||
|
const cell = (text, span = 1) =>
|
||||||
|
`<w:tc><w:tcPr>${span > 1 ? `<w:gridSpan w:val="${span}"/>` : ""}</w:tcPr>${text
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => `<w:p><w:r><w:t>${escape(line)}</w:t></w:r></w:p>`)
|
||||||
|
.join("")}</w:tc>`;
|
||||||
|
const docx = (rows) => {
|
||||||
|
const zip = new AdmZip();
|
||||||
|
zip.addFile(
|
||||||
|
"word/document.xml",
|
||||||
|
Buffer.from(
|
||||||
|
`<?xml version="1.0" encoding="UTF-8"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>` +
|
||||||
|
`<w:p><w:r><w:t>Расписание. Осенний семестр 2026/2027 учебного года</w:t></w:r></w:p>` +
|
||||||
|
`<w:tbl>${rows.map((r) => `<w:tr>${r.join("")}</w:tr>`).join("")}</w:tbl></w:body></w:document>`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return zip.toBuffer();
|
||||||
|
};
|
||||||
|
const schedule = parseDocx(
|
||||||
|
docx([
|
||||||
|
[cell("Дни"), cell("Часы"), cell("26-ИЯи-1", 2), cell("26-ИЯи-2", 2)],
|
||||||
|
[
|
||||||
|
cell("Вторник"),
|
||||||
|
cell("8:30-9:55"),
|
||||||
|
cell("Занятия в Полоцке"),
|
||||||
|
cell("Занятия в Полоцке\nФункциональная грамматика (п.з.) Иванова А.А. 249Ж"),
|
||||||
|
cell("Педагогика (л.) Петров П.П. 412н", 2),
|
||||||
|
],
|
||||||
|
[cell(""), cell("10:05-11:30"), cell("Педагогика (п.з.) Сидоров С.С. 205Г", 2), cell("Философия (п.з.) Козлов К.К. 510н", 2)],
|
||||||
|
[
|
||||||
|
cell("Среда"),
|
||||||
|
cell("8:30-9:55"),
|
||||||
|
cell("Латинский язык (п.з.) Нестер Н.В. 246(Новополоцк)", 2),
|
||||||
|
cell("г.Новополоцк\nРусский язык как иностранный (п.з.) Кириллова В.С. 533н"),
|
||||||
|
cell(""),
|
||||||
|
],
|
||||||
|
[cell("Четверг"), cell("8:30-9:55"), cell("История Полоцка (л.) Борун Е.Н. 100", 4)],
|
||||||
|
]),
|
||||||
|
{ name: "26-ИЯи.docx", year: "2026-2027" },
|
||||||
|
);
|
||||||
|
const file = { id: "places", name: "26-ИЯи.docx" };
|
||||||
|
const lesson = (day, start, group, subject) =>
|
||||||
|
schedule.lessons.find((l) => l.day === day && l.start === start && l.groups.includes(group) && l.subject.startsWith(subject));
|
||||||
|
|
||||||
|
test("Места: «Занятия в Полоцке» — не занятие, а город дня в столбце", () => {
|
||||||
|
assert.ok(!schedule.lessons.some((l) => /Занятия в/.test(l.subject)), "заметка не становится занятием");
|
||||||
|
assert.deepEqual(
|
||||||
|
schedule.places.map((p) => [p.day, p.groups.join(), p.subgroups.map((s) => s.label).join(), p.place, p.text]),
|
||||||
|
[
|
||||||
|
[2, "26-ИЯи-1", "Подгруппа 1", "Полоцк", "Занятия в Полоцке"],
|
||||||
|
[2, "26-ИЯи-1", "Подгруппа 2", "Полоцк", "Занятия в Полоцке"],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
// The lesson under the note, in the same cell.
|
||||||
|
const grammar = lesson(2, "08:30", "26-ИЯи-1", "Функциональная грамматика");
|
||||||
|
assert.equal(grammar.place, "Полоцк");
|
||||||
|
assert.equal(grammar.room, "249Ж");
|
||||||
|
// A lesson of the whole group: both subgroups have the note.
|
||||||
|
assert.equal(lesson(2, "10:05", "26-ИЯи-1", "Педагогика").place, "Полоцк");
|
||||||
|
// Another group the same day stays where it was.
|
||||||
|
assert.equal(lesson(2, "08:30", "26-ИЯи-2", "Педагогика").place, undefined);
|
||||||
|
assert.equal(lesson(2, "10:05", "26-ИЯи-2", "Философия").place, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Места: город в ячейке занятия — отдельной строкой или после аудитории", () => {
|
||||||
|
const latin = lesson(3, "08:30", "26-ИЯи-1", "Латинский язык");
|
||||||
|
assert.equal(latin.room, "246");
|
||||||
|
assert.equal(latin.place, "Новополоцк");
|
||||||
|
const russian = lesson(3, "08:30", "26-ИЯи-2", "Русский язык");
|
||||||
|
assert.equal(russian.subject, "Русский язык как иностранный");
|
||||||
|
assert.equal(russian.place, "Новополоцк");
|
||||||
|
// A subject that names a town is no place.
|
||||||
|
const history = lesson(4, "08:30", "26-ИЯи-1", "История Полоцка");
|
||||||
|
assert.equal(history.subject, "История Полоцка");
|
||||||
|
assert.equal(history.place, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Места: разбор заметок и строк с городом", () => {
|
||||||
|
assert.deepEqual(placeNote("Занятия в Полоцке"), { text: "Занятия в Полоцке", place: "Полоцк", rest: "" });
|
||||||
|
assert.deepEqual(placeNote("Занятия в г. Новополоцк\nИнформационный час ауд.В0-02"), {
|
||||||
|
text: "Занятия в г. Новополоцк",
|
||||||
|
place: "Новополоцк",
|
||||||
|
rest: "Информационный час ауд.В0-02",
|
||||||
|
});
|
||||||
|
assert.equal(placeNote("Занятия в Междуречье.").place, "Междуречье");
|
||||||
|
assert.equal(placeNote("Занятия в Полоцке Философия (л.)").rest, "Философия (л.)");
|
||||||
|
assert.equal(placeNote("История Полоцка (л.)"), null);
|
||||||
|
assert.equal(placeNote("Занятия в ауд. 412н"), null);
|
||||||
|
assert.deepEqual(lessonPlace("121 (г.Новополоцк)"), { place: "Новополоцк", rest: "121" });
|
||||||
|
// A venue after the town stays in the text.
|
||||||
|
assert.deepEqual(lessonPlace("г. Полоцк, Дом офицеров"), { place: "Полоцк", rest: "г. Полоцк, Дом офицеров" });
|
||||||
|
assert.deepEqual(lessonPlace("Философия (л.) Борун Е.Н. 100"), { place: null, rest: "Философия (л.) Борун Е.Н. 100" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Места: заметки дня для подгруппы — общие для сервера и PWA", () => {
|
||||||
|
const notes = [
|
||||||
|
{ day: 4, date: null, groups: ["26-ИЯи-2"], subgroups: [{ group: "26-ИЯи-2", label: "Подгруппа 1" }], place: "Новополоцк", text: "Занятия в Новополоцке" },
|
||||||
|
];
|
||||||
|
const labels = ["Подгруппа 1", "Подгруппа 2"];
|
||||||
|
const one = placesOn(notes, "26-ИЯи-2", labels, 4);
|
||||||
|
assert.deepEqual(one, [{ place: "Новополоцк", text: "Занятия в Новополоцке", subgroup: "Подгруппа 1" }]);
|
||||||
|
// All subgroups shown: the note says whose it is; the first subgroup: their own day.
|
||||||
|
assert.deepEqual(placesFor(one, null), one);
|
||||||
|
assert.deepEqual(placesFor(one, "Подгруппа 1"), [{ place: "Новополоцк", text: "Занятия в Новополоцке", subgroup: null }]);
|
||||||
|
assert.deepEqual(placesFor(one, "Подгруппа 2"), []);
|
||||||
|
assert.deepEqual(placesOn(notes, "26-ИЯи-1", labels, 4), []);
|
||||||
|
// Both subgroups of 26-ИЯи-1 on Tuesday: one note for the whole group.
|
||||||
|
assert.deepEqual(placesOn(schedule.places, "26-ИЯи-1", labels, 2), [{ place: "Полоцк", text: "Занятия в Полоцке", subgroup: null }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Места в /api/timetable: город дня и занятия", () => {
|
||||||
|
// Tuesday 13 and Wednesday 14 October 2026.
|
||||||
|
const one = timetable({ file, schedule, calendar, group: "26-ИЯи-1", from: "2026-10-13", days: 2 });
|
||||||
|
const [tuesday, wednesday] = one.days;
|
||||||
|
// The note in both subgroups is the whole group's.
|
||||||
|
assert.deepEqual(tuesday.places, [{ place: "Полоцк", text: "Занятия в Полоцке", subgroup: null }]);
|
||||||
|
assert.ok(tuesday.lessons.every((l) => l.place === "Полоцк"));
|
||||||
|
assert.equal(wednesday.places, undefined);
|
||||||
|
assert.equal(wednesday.lessons[0].place, "Новополоцк");
|
||||||
|
assert.deepEqual(one.weekly.find((d) => d.day === 2).places, tuesday.places);
|
||||||
|
const two = timetable({ file, schedule, calendar, group: "26-ИЯи-2", from: "2026-10-13", days: 1 });
|
||||||
|
assert.equal(two.days[0].places, undefined);
|
||||||
|
assert.ok(two.days[0].lessons.every((l) => l.place === null));
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user