Compare commits
2 Commits
046e3c6a2f
...
d04b364047
| Author | SHA1 | Date | |
|---|---|---|---|
| d04b364047 | |||
| e3074555a7 |
@@ -11,3 +11,9 @@ BOARD_PWA_URL=https://pwa.ridebus.by/
|
||||
OPEN_METEO_API_KEY=
|
||||
PORT=5180
|
||||
HOST=127.0.0.1
|
||||
# Managed displays: pair each browser in the admin's Devices section.
|
||||
BOARD_REMOTE_SETTINGS=false
|
||||
# Optional URL shown on the pairing screen.
|
||||
BOARD_ADMIN_URL=
|
||||
# Set true behind an HTTPS reverse proxy if it does not send X-Forwarded-Proto.
|
||||
BOARD_COOKIE_SECURE=false
|
||||
|
||||
@@ -12,7 +12,7 @@ COPY package*.json ./
|
||||
RUN npm ci --omit=dev --no-audit --no-fund
|
||||
COPY --from=build /app/dist ./dist
|
||||
COPY server ./server
|
||||
COPY --from=build /app/src/lib/timetable.mjs /app/src/lib/weather.mjs ./src/lib/
|
||||
COPY --from=build /app/src/lib/timetable.mjs /app/src/lib/weather.mjs /app/src/lib/cancellations.mjs ./src/lib/
|
||||
RUN node --input-type=module -e "await import('./server/index.mjs')"
|
||||
USER node
|
||||
EXPOSE 5180
|
||||
|
||||
28
README.md
@@ -18,10 +18,14 @@ npm start
|
||||
|
||||
## Возможности
|
||||
|
||||
- Главный экран целиком отдан рейсам: ближайший крупно и следующие 2–6 рейсов. Строки рассчитываются по фактической доступной высоте, нижняя панель занимает отдельное место. Если все строки не помещаются с читаемым размером текста, список сменяется страницами каждые 7 секунд с явным счётчиком. На 879×593 и 390×667 шесть рейсов показаны страницами по три; на 855×723 четыре помещаются одновременно. В узкой компоновке время отправления находится под названием и не пересекается с ожиданием.
|
||||
- Главный экран целиком отдан рейсам: ближайший крупно и следующие 2–6 рейсов. Строки рассчитываются по фактической доступной высоте, нижняя панель занимает отдельное место. Если все строки не помещаются с читаемым размером текста, список сменяется страницами с явным счётчиком: каждые 7 секунд для телевизора и 10 секунд для уличного табло. В стандартном профиле на 879×593 и 390×667 шесть рейсов показаны страницами по три; на 855×723 четыре помещаются одновременно. В узкой компоновке время отправления находится под названием и не пересекается с ожиданием.
|
||||
- Точная отмена выделяется непосредственно в строке рейса: время остаётся видимым, отсчёт заменяется пометкой «Отменён». Ближайшим становится следующий доступный рейс. Общие сообщения без однозначного времени и остановки показывают «Есть изменения» и сохраняют исходный текст предупреждения.
|
||||
- Под ближайшим отправлением — время следующего рейса того же маршрута в том же направлении. Отменённые отправления пропускаются, завтрашняя дата отмечается один раз. Переключатель находится в настройках → «Экраны».
|
||||
- При ожидании от 90 минут крупно показывается фактическое время отправления с подписью «Сегодня в» или «Завтра в». При приближении рейса возвращается обычный отсчёт. Режим можно отключить в настройках → «Экраны».
|
||||
- Настройки → «Оформление» → «Читаемость»: профили «Телевизор» / «Уличное табло» и расстояние «Близко» / «Средне» / «Далеко». Для улицы и дальнего просмотра текст крупнее, одновременно видимых строк меньше. Уличный профиль использует спокойное короткое появление вместо вертикальной прокрутки текста. Информационные карточки при дальнем просмотре показываются по одной.
|
||||
- В шапке — название и направление конкретной площадки из API, часы и дата в `Europe/Minsk`. Поле «основная» не выдаётся за направление; если направления нет, оно не придумывается. Город, подпись «Остановка», «RideBus API» и «Все направления» убраны из шапки.
|
||||
- Информационный экран: маршруты, погода, QR-код и предупреждения. Часы и ближайший рейс с цветным номером остаются видимыми. В вертикальной и короткой компоновке показывается одна карточка; при следующих посещениях информационного экрана карточки сменяются. Их также можно выбрать кнопками «Маршруты», «Погода», «RideBus».
|
||||
- Блок маршрутов показывает только номер и название. На широком экране список занимает 2–3 колонки, в узкой карточке — одну. Вместимость определяется доступной высотой; остальные маршруты сменяются страницами каждые 6,5 секунды с плавным переходом названий и чисел. Пауза останавливает смену страниц. Тип транспорта, повторные времена, наклонённые дорожки, фоновые рисунки и технический статус кеша убраны.
|
||||
- Информационный экран: первый и последний рейс, погода, QR-код и предупреждения. Карточка движения показывает последний рейс сегодня и первый завтра с номером и направлением по полному расписанию, с учётом фильтра маршрута и прибытий после полуночи. Без полного расписания время не подставляется из короткого списка ближайших рейсов. Часы и ближайший рейс остаются видимыми. В вертикальной и короткой компоновке карточки сменяются при следующих посещениях экрана; их также можно выбрать кнопками «Движение», «Погода», «RideBus».
|
||||
- В настройках → «Экраны» → «Блоки информации» каждый из трёх блоков можно включить или отключить. Оставшиеся карточки занимают доступную ширину, отключённые пропускаются в компактном цикле. При отключении всех блоков информационный экран исключается из обычного цикла; активные предупреждения о движении всё равно показываются. Выбор сохраняется в браузере; «Сбросить цикл» включает все блоки.
|
||||
- Уведомления об отменах, объездах, задержках и другие сообщения оформлены простой карточкой с иконкой, номерами маршрутов и сроком действия. Исходные заголовки, тексты и детали отмен сохраняются. Статус предупреждений отображается только при их недоступности.
|
||||
- Цикл 25 секунд / 8 секунд, настраиваемый. До ближайшего рейса ≤2 минут главный экран удерживается; можно отключить в настройках. Отмены и объезды исключают рекламную сцену из цикла.
|
||||
- Опциональная рекламная сцена с текстом и длительностью; по умолчанию выключена, помечена как предпросмотр. Загрузка медиа и кампании в MVP не входят.
|
||||
@@ -46,6 +50,8 @@ npm start
|
||||
|
||||
При сбое появляется пометка сохранённых данных. Истёкшие рейсы удаляются. Если сохранённые рейсы закончились, табло честно сообщает об этом и продолжает переподключение. Предупреждения фильтруются по городу, маршрутам, срокам действия; сохраняются полный текст и детали отмены. Полный кеш расписания не подтверждает актуальность предупреждений — без свежего ответа API это явно указано.
|
||||
|
||||
Для сопоставления отмен используется точный формат действующего Telegram-импортера API: дата, «не выполняются рейсы», название остановки и перечень времён; поддерживаются секции отдельных маршрутов. Сохраняются отправная остановка, время и день обслуживания, чтобы отмена правильно сопоставлялась на следующих остановках и после полуночи. Неоднозначные названия площадок или несколько совпавших рейсов не помечаются отменёнными автоматически. Устаревшие, будущие и истёкшие предупреждения не исключают отправления.
|
||||
|
||||
QR-код создаётся локально, без стороннего сервиса генерации, и открывает `https://pwa.ridebus.by/?city=1&stop=1` для текущей остановки (параметры меняются вместе с площадкой). Адрес PWA можно переопределить серверной переменной `BOARD_PWA_URL`. В карточке — большой неподвижный QR, одна инструкция и ссылка. Размер кода рассчитывается по свободному месту. QR использует скруглённые чёрные модули, белый фон и свободное поле шириной 4 модуля. Декодирование нескольких ссылок и QR на снимках страницы проверено автоматически.
|
||||
|
||||
Погода запрашивается независимо через `/api/weather` у [Open-Meteo](https://open-meteo.com/en/docs) по публичным координатам остановки из API. В шапке — температура и состояние; на информационном экране — текущая температура, состояние и прогноз температуры с крупными иконками на три следующих часа. Это модельные погодные данные. Обновление раз в 10 минут; сохранённая погода помечается и показывается не дольше часа, затем скрывается. При отсутствии координат или сбое погоды транспорт продолжает работать. Данные сохраняются по городу и остановке; RideBus-ключ не передаётся поставщику погоды. Указаны источник и CC BY 4.0.
|
||||
@@ -66,6 +72,22 @@ npm run probe
|
||||
|
||||
## Развёртывание
|
||||
|
||||
### Удалённое управление из polotsk-transit-api
|
||||
|
||||
Сначала примените миграцию `backend/migrations/016_board_devices.sql` в базе API и разверните обновлённые backend/frontend `polotsk-transit-api`. Для существующей базы выполняйте именно новую миграцию: старый общий скрипт проекта повторяет все SQL-файлы, включая создание уже существующих таблиц.
|
||||
|
||||
В `.env` табло включите `BOARD_REMOTE_SETTINGS=true`. `RIDEBUS_API_BASE_URL` указывает на обновлённый сервер API, `RIDEBUS_API_KEY` остаётся только на сервере Board. Необязательный `BOARD_ADMIN_URL` — полный HTTPS-адрес раздела устройств вашей админки. Compose передаёт все эти параметры в контейнер. После изменения переменных пересоберите/пересоздайте контейнер.
|
||||
|
||||
При первом запуске каждый браузер показывает собственный четырёхзначный код, действующий 5 минут. Администратор открывает «Устройства», добавляет табло по коду, выбирает название, город (через переключатель сети), конкретную площадку и настройки. Код одноразовый; после привязки табло запускается автоматически. Смена остановки, маршрута, темы, палитры, профиля, числа строк, экранов, информационных блоков и рекламы применяется в течение следующего опроса, без обновления страницы. Деморежим удалённой конфигурации выключен.
|
||||
|
||||
Во время привязки опрос каждые 3 секунды, после — каждые 15. Сигнал работы каждые 30 секунд и отдельное подтверждение после применения новой версии. Админка различает «Ожидают применения» и «Настройки применены»; после 90 секунд без связи устройство считается офлайн. Конкурирующие изменения обнаруживаются по версии, старое сохранение не перезаписывает новое.
|
||||
|
||||
Идентичность устройства — отдельный профиль браузера на отдельном origin. Вкладки одного браузера с одним адресом относятся к одному устройству. Токен хранится в HttpOnly cookie и не передаётся в JavaScript или URL; на API сохраняется только SHA-256 хеш. Для сервера с HTTPS нужен `Secure` cookie: передавайте `X-Forwarded-Proto: https` через reverse proxy либо задайте `BOARD_COOKIE_SECURE=true`. Очищенные cookies, смена адреса или профиля требуют новой привязки. Не запускайте табло в приватном режиме.
|
||||
|
||||
При потере управления табло использует последнюю проверенную конфигурацию и существующий кеш расписания. Локальная конфигурация автономного режима хранится отдельно. После привязки меню на телевизоре показывает состояние управления, имя устройства и версию; параметры меняются в админке. «Отвязать» отзывает токен; экран прекращает показ при следующей связи с API. Для новой привязки нажмите «Подключить заново» на табло. При отключённом `BOARD_REMOTE_SETTINGS` сохраняется прежняя локальная настройка.
|
||||
|
||||
Для локальной совместной проверки после сборки обоих проектов: `node test/fixtures/device-flow-server.mjs`. Это явно обозначенный тестовый стенд с настоящими устройственными обработчиками и UI, но с памятью вместо PostgreSQL и тестовым транспортом. Админка — `http://127.0.0.1:5190/devices`, табло — `http://127.0.0.1:5191`; тестовый вход `board-fixture / fixture-only`. Этот файл не входит в Docker runtime и не предназначен для публикации.
|
||||
|
||||
```sh
|
||||
docker compose up --build -d
|
||||
```
|
||||
|
||||
BIN
artifacts/departure-features-1079x591.jpg
Normal file
|
After Width: | Height: | Size: 53 KiB |
BIN
artifacts/departure-features-855x723.jpg
Normal file
|
After Width: | Height: | Size: 59 KiB |
BIN
artifacts/departure-features-all-cancelled.jpg
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
artifacts/departure-features-approaching-far.jpg
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
artifacts/departure-features-cancellation.jpg
Normal file
|
After Width: | Height: | Size: 87 KiB |
BIN
artifacts/departure-features-live.jpg
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
artifacts/departure-features-long-wait-light.jpg
Normal file
|
After Width: | Height: | Size: 92 KiB |
BIN
artifacts/departure-features-overnight-390x667.jpg
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
artifacts/departure-features-overnight-879x593.jpg
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
artifacts/departure-features-street-far-390x667.jpg
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
artifacts/device-management-offline.jpg
Normal file
|
After Width: | Height: | Size: 78 KiB |
BIN
artifacts/device-pairing-screen.jpg
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
artifacts/device-remote-light.jpg
Normal file
|
After Width: | Height: | Size: 70 KiB |
BIN
artifacts/device-repairing.jpg
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
artifacts/devices-admin-applied.jpg
Normal file
|
After Width: | Height: | Size: 55 KiB |
BIN
artifacts/info-block-settings-879x593.jpg
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
artifacts/info-blocks-disabled.jpg
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
artifacts/info-service-1079x591.jpg
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
artifacts/info-service-390x667.jpg
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
artifacts/info-service-855x723.jpg
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
artifacts/info-service-879x593.jpg
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
artifacts/info-service-light.jpg
Normal file
|
After Width: | Height: | Size: 106 KiB |
BIN
artifacts/info-service-one-block.jpg
Normal file
|
After Width: | Height: | Size: 91 KiB |
BIN
artifacts/info-service-three-cards.jpg
Normal file
|
After Width: | Height: | Size: 106 KiB |
BIN
artifacts/info-service-two-blocks.jpg
Normal file
|
After Width: | Height: | Size: 104 KiB |
26
artifacts/verification-departure-features.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# Проверка четырёх функций табло — 9 октября 2026
|
||||
|
||||
Добавлены отмены в строках отправлений, следующий рейс того же направления, крупное время при ожидании от 90 минут и профили читаемости.
|
||||
|
||||
## Автоматические проверки
|
||||
|
||||
- `npm test`: 64 теста прошли. Проверены точные и общие уведомления, несколько маршрутов, неоднозначные площадки/рейсы, устаревшие предупреждения, прибытия после полуночи, следующий рейс и сохранение настроек.
|
||||
- `npm run build`: финальная TypeScript/Vite-сборка. Временная визуальная сборка исключена из итогового `dist`.
|
||||
- `git diff --check`: без ошибок форматирования.
|
||||
- Импорт серверных модулей из изолированной структуры runtime прошёл. В Dockerfile добавлен общий модуль обработки отмен. Docker-контейнер не запускался.
|
||||
|
||||
## Браузер
|
||||
|
||||
Основная страница проверена с настоящими данными остановки «Швейная фабрика»: следующий рейс того же направления отображается, завтрашнее время не дублируется. После перезапуска локального сервера API вернул расписание 22 маршрутов с метаданными отправной остановки и дня обслуживания. Временный сбой upstream вернул 502; повторный запрос завершился успешно.
|
||||
|
||||
Отмена и большие интервалы проверены на явно обозначенных тестовых данных с настоящим компонентом `DeparturesScreen`; исходник сценариев находится в `test/fixtures/board-features.tsx`. Тестовые отмены не выдавались за действующие сообщения API.
|
||||
|
||||
- **1079×591, 6 строк:** страницы по три строки, высота строк 73–74 px, промежуток между временем и ожиданием около 24 px, содержимое не переполняет список.
|
||||
- **855×723, 4 строки:** четыре строки одновременно, высота 76–77 px, без пересечений.
|
||||
- **879×593:** отменённый рейс пропускается при выборе ближайшего; ночной сценарий показывает «Завтра в 05:40» и следующий рейс «06:20 завтра».
|
||||
- **390×667:** длинное ожидание, переход к отсчёту за 10 минут, длинные названия, все рейсы отменены; в профиле «улица / далеко» одна крупная строка на странице.
|
||||
- **1280×900:** тёмная тема с отменами и светлая тема с крупным временем «Сегодня в 14:05».
|
||||
- Автоматическая смена страниц уличного профиля наблюдалась через 10 секунд; пауза останавливает смену.
|
||||
- На реальной странице профиль «улица / далеко» и выключенные новые функции сохранились после перезагрузки. В меню 879×593 новые поля доступны через постоянные кнопки «Ниже». Затем восстановлены «телевизор / близко», оба новых переключателя включены, удержание ближайшего рейса включено, 4 строки, тёмная лаймовая тема.
|
||||
|
||||
Снимки `departure-features-*.jpg` находятся рядом с этим файлом. Итоговый пример отмены — `departure-features-cancellation.jpg`. Физический Xiaomi Mi Box 4 с Яндекс Браузером не проверялся.
|
||||
37
artifacts/verification-device-management.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# Удалённое управление табло — 10 октября 2026
|
||||
|
||||
Изменения сделаны совместно в `/Users/xtimms/Projects/ridebus-board` и `/Users/xtimms/Projects/polotsk-transit-api`.
|
||||
|
||||
## Результат
|
||||
|
||||
- При включённом `BOARD_REMOTE_SETTINGS` новое устройство показывает четырёхзначный код с TTL 5 минут; привязка выполняется администратором в разделе «Устройства» выбранного города.
|
||||
- Индивидуальный токен в HttpOnly cookie, на API только хеш. Код занимается транзакционно, параметры остановки/маршрута проверяются по городу. Отдельные права устройства и администратора.
|
||||
- Настройки с версиями и оптимистическим сохранением. Подтверждение применения от телевизора; состояние связи с порогом 90 секунд.
|
||||
- Удалённые настройки всех существующих параметров табло, ограниченное локальное меню, кеш конфигурации, отказ от показа после получения отзыва доступа, повторное подключение по новому коду.
|
||||
- Автономный режим сохранён; параметр управления по умолчанию выключен до развёртывания обновлённого API.
|
||||
|
||||
## Проверки
|
||||
|
||||
- Board: 66 тестов прошли; `npm run build` (TypeScript + Vite) успешен.
|
||||
- API: полный набор — 96 прошли, 15 пропущены из-за отсутствия отдельной PostgreSQL БД. Новые целевые проверки — 7 прошли, 1 PostgreSQL-тест пропущен. Повторная целевая проверка после уточнения лимитов тоже успешна.
|
||||
- Admin: Vite production build успешен; существующие предупреждения о Browserslist и размере общего bundle.
|
||||
- `git diff --check` без ошибок в обоих проектах.
|
||||
- Сервер Board импортирован из изолированного дерева с тем же набором общих файлов, который копируется в Docker runtime. Docker-контейнер не запускался: локальный daemon недоступен.
|
||||
|
||||
## Совместный браузерный стенд
|
||||
|
||||
Использованы настоящие обработчики API устройств, сервис, мост Board и UI обоих проектов. Репозиторий устройств в памяти и транспортные данные явно тестовые; это не доказательство работы с производственной БД. Файл стенда: `test/fixtures/device-flow-server.mjs`, тестовый сервер доступен только на loopback и остановлен после проверки.
|
||||
|
||||
Проверены через интерфейс:
|
||||
|
||||
1. Вход тестового администратора и раздел «Устройства».
|
||||
2. Ввод кода, автоматический переход табло к расписанию, статус «В сети / Настройки применены · v1».
|
||||
3. Изменение остановки на «Автовокзал», светлая зелёная тема, уличный профиль и дальний просмотр; автоматическое применение без перезагрузки, затем «Настройки применены · v2» в админке.
|
||||
4. Перезагрузка сохраняет привязку; локальное меню показывает «Управляется из админки» и версию 2.
|
||||
5. Отвязка в админке прекращает показ; кнопка на табло получает новый код.
|
||||
6. Недоступность серверной части управления и перезагрузка страницы: восстановлены имя остановки и параметры из кеша, показаны сообщения о сохранённом расписании и отсутствии связи с управлением.
|
||||
7. Восстановление связи: устройство снова «В сети», сохранённая конфигурация подтверждена.
|
||||
|
||||
Снимки: `device-pairing-screen.jpg`, `devices-admin-applied.jpg`, `device-remote-light.jpg`, `device-repairing.jpg`, `device-management-offline.jpg`.
|
||||
|
||||
Миграция 016 подготовлена, но к рабочей базе не применялась. Обновлённые API и админка на сервер не выкладывались. Физический Xiaomi Mi Box 4 не проверялся. Исходный локальный Board на 5180 перезапущен с новым серверным кодом, его автономные настройки сохранены; тестовые вкладки закрыты.
|
||||
13
artifacts/verification-info-blocks.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# Информационные блоки — 9 октября 2026
|
||||
|
||||
- Вместо списка маршрутов: последний рейс сегодня и первый завтра, номер и направление. Источник — полное расписание текущей остановки; короткий live-ответ не используется для определения границ движения.
|
||||
- В настройках → «Экраны» доступны три независимых переключателя. Старые настройки включают все блоки; отключения сохраняются после перезагрузки. «Сбросить цикл» возвращает блоки.
|
||||
- Проверены три, два, один и ноль блоков через интерфейс. При одном блоке нет лишних вкладок, при нуле нет обычной информационной сцены; ручной переход сохраняет главный экран. Предупреждения остаются в цикле независимо от переключателей, реклама подавляется при отменах/объездах — проверено тестами логики сцен.
|
||||
- Браузер: 1280×900 (три блока, тёмная/светлая тема), 879×593, 1079×591, 855×723, 390×667. Новый блок помещается в карточку без внутренней прокрутки. Уточнены размеры и отступы короткой вертикальной карточки.
|
||||
- На 879×593 переключатели доступны через постоянные кнопки «Ниже»/«Выше». Проверена работа кнопок курсором; физический Xiaomi Mi Box 4 не проверялся.
|
||||
- Сверка с локальным `/api/timetable?stopId=1`: 1031 прибытие в развёрнутом расписании за сегодня и завтра; последний сегодня №6, 23:55, Баграмяна; первый завтра №22, 00:02, Больница (Новка). Время суток — Europe/Minsk, прибытию после полуночи соответствует следующий календарный день.
|
||||
- `npm test`: 54 теста прошли, включая миграцию переключателей, все выключенные блоки, предупреждения, фильтр маршрута и переход через полночь/Новый год. API-тест запущен с разрешением на локальный порт после ограничения песочницы.
|
||||
- `npm run build` и `git diff --check` прошли. Финальный JS: `index-GhNzOttK.js`, CSS: `index-DfZr3qbx.css`.
|
||||
- Исходные настройки возвращены: тёмная тема, лайм, удержание главного экрана включено, все три блока включены. Временный размер браузера сброшен; автоматическая смена экранов возобновлена.
|
||||
|
||||
Снимки: `info-service-three-cards.jpg`, `info-service-light.jpg`, `info-service-879x593.jpg`, `info-service-1079x591.jpg`, `info-service-855x723.jpg`, `info-service-390x667.jpg`, `info-service-two-blocks.jpg`, `info-service-one-block.jpg`, `info-block-settings-879x593.jpg`, `info-blocks-disabled.jpg`.
|
||||
@@ -12,5 +12,8 @@ services:
|
||||
BOARD_STOP_ID: ${BOARD_STOP_ID:-1}
|
||||
BOARD_PWA_URL: ${BOARD_PWA_URL:-https://pwa.ridebus.by/}
|
||||
OPEN_METEO_API_KEY: ${OPEN_METEO_API_KEY:-}
|
||||
BOARD_REMOTE_SETTINGS: ${BOARD_REMOTE_SETTINGS:-false}
|
||||
BOARD_ADMIN_URL: ${BOARD_ADMIN_URL:-}
|
||||
BOARD_COOKIE_SECURE: ${BOARD_COOKIE_SECURE:-false}
|
||||
ports:
|
||||
- "127.0.0.1:${BOARD_PORT:-5180}:5180"
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { loadTimetable } from './timetable.mjs';
|
||||
import { parseCancellationNotice } from '../src/lib/cancellations.mjs';
|
||||
import { shiftDate } from '../src/lib/timetable.mjs';
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(status = 502, code = 'data_unavailable') { super(code); this.status = status; this.code = code; }
|
||||
@@ -38,8 +40,11 @@ export function normalizeDepartures(raw, now, stopNames = new Map()) {
|
||||
const terminal = stopNames.get(Number(arrival.trip?.endStopId));
|
||||
const destination = terminal || route.name.split(/\s+[-–—]\s+/).at(-1) || route.name;
|
||||
const identity = [route.id, scheduledAt, arrival.trip?.departureTime || '', arrival.trip?.startStopId || '', arrival.trip?.endStopId || '', note].join(':');
|
||||
const originTime = scheduledInstant(arrival.trip?.departureTime, now)?.slice(11, 16);
|
||||
departures.push({ id: identity, routeId: route.id, routeNumber: route.number, routeName: route.name,
|
||||
destination, type: route.type, scheduledAt, scheduledTime: clean(arrival.scheduledTime).slice(0, 5), note,
|
||||
destination, type: route.type, scheduledAt, scheduledTime: scheduledAt.slice(11, 16), note,
|
||||
serviceDate: originTime && originTime > scheduledAt.slice(11, 16) ? shiftDate(localDate(now), -1) : localDate(now),
|
||||
originStopId: Number(arrival.trip?.startStopId) || undefined, originTime,
|
||||
source: 'schedule' });
|
||||
}
|
||||
}
|
||||
@@ -47,7 +52,7 @@ export function normalizeDepartures(raw, now, stopNames = new Map()) {
|
||||
routes: routes.sort((a, b) => a.number.localeCompare(b.number, 'ru', { numeric: true })), partial };
|
||||
}
|
||||
|
||||
export function normalizeAlerts(raw, routes, cityId, now) {
|
||||
export function normalizeAlerts(raw, routes, cityId, now, stops = []) {
|
||||
if (!Array.isArray(raw)) throw new ApiError();
|
||||
const routeIds = new Set(routes.map(route => route.id));
|
||||
const routeNumbers = new Set(routes.map(route => route.number));
|
||||
@@ -66,10 +71,12 @@ export function normalizeAlerts(raw, routes, cityId, now) {
|
||||
if (!numbers.length && clean(alert.route_number)) numbers.push(clean(alert.route_number));
|
||||
const targets = ids(alert.route_ids);
|
||||
if (!targets.length && Number(alert.route_id) > 0) targets.push(Number(alert.route_id));
|
||||
if (!numbers.length) numbers.push(...routes.filter(route => targets.includes(route.id)).map(route => route.number));
|
||||
const value = { id: String(alert.id), title: clean(alert.title) || 'Изменения движения', message: clean(alert.message),
|
||||
type: ['cancellation', 'delay', 'detour'].includes(alert.alert_type) ? alert.alert_type : 'info',
|
||||
routeNumbers: [...new Set(numbers)], routeIds: [...new Set(targets)],
|
||||
startAt: alert.start_time || null, endAt: alert.end_time || null };
|
||||
if (value.type === 'cancellation') value.cancellations = parseCancellationNotice(value.message, value.routeNumbers, stops);
|
||||
return [value.id, value];
|
||||
})).values()];
|
||||
}
|
||||
@@ -139,7 +146,7 @@ export function createBoardClient({
|
||||
const data = normalizeDepartures(eta, generatedAt, new Map(stops.map(stop => [Number(stop.id), clean(stop.name)])));
|
||||
const stopRoutes = Array.isArray(stop.routes) ? stop.routes.filter(route => route?.is_active !== false).map(routeInfo) : data.routes;
|
||||
let alerts = [], alertsAvailable = true;
|
||||
try { alerts = normalizeAlerts(await cached('alerts', 20000, () => upstream('api/v1/alerts/active')), stopRoutes, cityId, now()); }
|
||||
try { alerts = normalizeAlerts(await cached('alerts', 20000, () => upstream('api/v1/alerts/active')), stopRoutes, cityId, now(), stops); }
|
||||
catch { alertsAvailable = false; }
|
||||
return { cityId, cityName, stop: { id: stopId, name: clean(stop.name), direction: clean(stop.description) },
|
||||
...data, alerts, alertsAvailable, updatedAt: new Date(generatedAt).toISOString(), serviceDate: localDate(generatedAt), mode: 'live' };
|
||||
|
||||
59
server/device.mjs
Normal file
@@ -0,0 +1,59 @@
|
||||
import { ApiError } from './board.mjs';
|
||||
|
||||
const COOKIE = 'ridebus_board_device';
|
||||
const tokenFrom = req => { const value = req.headers.cookie?.split(';').map(item => item.trim()).find(item => item.startsWith(`${COOKIE}=`))?.slice(COOKIE.length + 1); return /^[A-Za-z0-9_-]{43}$/.test(value || '') ? value : ''; };
|
||||
export function createDeviceBridge({ enabled = process.env.BOARD_REMOTE_SETTINGS === 'true', baseUrl = process.env.RIDEBUS_API_BASE_URL || 'https://api.ridebus.by/', adminUrl = process.env.BOARD_ADMIN_URL || '', fetchData = fetch } = {}) {
|
||||
const cache = new Map();
|
||||
async function upstream(path, token, body) {
|
||||
let response;
|
||||
try { response = await fetchData(new URL(`api/v1/board-devices/${path}`, baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`), {
|
||||
method: body === undefined ? 'GET' : 'POST', headers: { Accept: 'application/json', ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}), ...(token ? { 'X-Board-Token': token } : {}) },
|
||||
...(body !== undefined ? { body: JSON.stringify(body) } : {}), redirect: 'error', signal: AbortSignal.timeout(10000),
|
||||
}); } catch { throw new ApiError(502, 'devices_unavailable'); }
|
||||
if (!response.ok) {
|
||||
const codes = { 401: 'device_unpaired', 410: 'device_revoked', 429: 'device_rate_limit' };
|
||||
throw new ApiError(codes[response.status] ? response.status : 502, codes[response.status] || 'devices_unavailable');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
async function status(req, force = false) {
|
||||
const token = tokenFrom(req);
|
||||
if (!token) return { state: 'unpaired' };
|
||||
const cached = cache.get(token);
|
||||
if (!force && cached?.until > Date.now()) return cached.value;
|
||||
try {
|
||||
const value = await upstream('self', token);
|
||||
if (cache.size >= 1000) cache.delete(cache.keys().next().value);
|
||||
cache.set(token, { value, until: Date.now() + 15000 }); return value;
|
||||
} catch (error) { cache.delete(token); if (error.code === 'device_unpaired') return { state: 'unpaired' }; throw error; }
|
||||
}
|
||||
return {
|
||||
enabled,
|
||||
async status(req, config) {
|
||||
if (!enabled) return { enabled: false };
|
||||
const value = await status(req, true);
|
||||
return { ...value, enabled: true, adminUrl, ...(value.state === 'paired' ? { config: { ...config, cityId: value.device.cityId, cityName: value.device.cityName, stopName: value.device.stopName, defaultStopId: value.device.settings.stopId } } : {}) };
|
||||
},
|
||||
async start(req, res) {
|
||||
if (!enabled) throw new ApiError(404, 'not_found');
|
||||
const previous = await status(req, true).catch(error => { if (error.code === 'device_revoked') return { state: 'revoked' }; throw error; });
|
||||
if (['paired', 'pending'].includes(previous.state)) return { ...previous, enabled: true, adminUrl };
|
||||
const value = await upstream('pairings', '', req.body || {});
|
||||
if (!/^[A-Za-z0-9_-]{43}$/.test(value.token || '')) throw new ApiError(502, 'devices_unavailable');
|
||||
const oldToken = tokenFrom(req); if (oldToken) cache.delete(oldToken);
|
||||
res.cookie(COOKIE, value.token, { httpOnly: true, sameSite: 'strict', secure: req.secure || req.get('X-Forwarded-Proto') === 'https' || process.env.BOARD_COOKIE_SECURE === 'true', path: '/api', maxAge: 365 * 86400000 });
|
||||
const { token: _token, ...publicValue } = value;
|
||||
return { ...publicValue, enabled: true, adminUrl };
|
||||
},
|
||||
async heartbeat(req) {
|
||||
if (!enabled || !tokenFrom(req)) throw new ApiError(401, 'device_unpaired');
|
||||
return upstream('self/heartbeat', tokenFrom(req), req.body || {});
|
||||
},
|
||||
async context(req) {
|
||||
if (!enabled) return null;
|
||||
const value = await status(req);
|
||||
if (value.state !== 'paired') throw new ApiError(401, 'device_unpaired');
|
||||
return value.device;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -4,37 +4,58 @@ import { fileURLToPath } from 'node:url';
|
||||
import { resolve } from 'node:path';
|
||||
import { ApiError, createBoardClient } from './board.mjs';
|
||||
import { createWeatherClient } from './weather.mjs';
|
||||
import { createDeviceBridge } from './device.mjs';
|
||||
|
||||
export function createApp(client = createBoardClient(), weatherClient = createWeatherClient()) {
|
||||
export function createApp(client = createBoardClient(), weatherClient = createWeatherClient(), deviceBridge = createDeviceBridge(), clientFactory = createBoardClient) {
|
||||
const app = express();
|
||||
app.disable('x-powered-by');
|
||||
app.use((_req, res, next) => { res.setHeader('X-Content-Type-Options', 'nosniff'); next(); });
|
||||
app.get('/healthz', (_req, res) => res.json({ status: 'ok' }));
|
||||
app.use('/api/device', express.json({ limit: '8kb' }), (req, res, next) => {
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
if (req.method !== 'GET') {
|
||||
let origin; try { origin = req.get('Origin') ? new URL(req.get('Origin')) : null; } catch { return res.status(403).json({ error: 'invalid_origin' }); }
|
||||
if (req.get('Sec-Fetch-Site') === 'cross-site' || origin && origin.host !== req.get('Host') || !req.is('application/json')) return res.status(403).json({ error: 'invalid_origin' });
|
||||
}
|
||||
next();
|
||||
});
|
||||
app.get('/api/device/status', async (req, res) => res.json(await deviceBridge.status(req, client.config())));
|
||||
app.post('/api/device/pair', async (req, res) => res.json(await deviceBridge.start(req, res)));
|
||||
app.post('/api/device/heartbeat', async (req, res) => res.json(await deviceBridge.heartbeat(req)));
|
||||
const clients = new Map();
|
||||
app.use('/api', (req, res, next) => {
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
if (req.method !== 'GET') { res.setHeader('Allow', 'GET'); res.status(405).json({ error: 'method_not_allowed' }); return; }
|
||||
next();
|
||||
});
|
||||
app.use('/api', async (req, _res, next) => {
|
||||
if (!deviceBridge.enabled || req.path === '/config') { req.boardClient = client; return next(); }
|
||||
const device = await deviceBridge.context(req);
|
||||
if (!clients.has(device.cityId)) clients.set(device.cityId, clientFactory({ cityId: device.cityId, cityName: device.cityName, stopName: device.stopName, defaultStopId: device.settings.stopId }));
|
||||
req.boardClient = clients.get(device.cityId);
|
||||
if (req.query.stopId && req.query.stopId !== String(device.settings.stopId)) throw new ApiError(409, 'settings_changed');
|
||||
next();
|
||||
});
|
||||
app.get('/api/config', (_req, res) => res.json(client.config()));
|
||||
app.get('/api/stops', async (_req, res) => { res.json(await client.stops()); });
|
||||
app.get('/api/stops', async (req, res) => { res.json(await req.boardClient.stops()); });
|
||||
app.get('/api/board', async (req, res) => {
|
||||
const raw = req.query.stopId;
|
||||
if (typeof raw !== 'string' || !/^[1-9]\d*$/.test(raw)) throw new ApiError(400, 'invalid_stop');
|
||||
const data = await client.board(Number(raw));
|
||||
const data = await req.boardClient.board(Number(raw));
|
||||
res.json({ ...data, serverTime: new Date().toISOString() });
|
||||
});
|
||||
const stopId = req => {
|
||||
if (typeof req.query.stopId !== 'string' || !/^[1-9]\d*$/.test(req.query.stopId) || !Number.isSafeInteger(Number(req.query.stopId))) throw new ApiError(400, 'invalid_stop');
|
||||
return Number(req.query.stopId);
|
||||
};
|
||||
app.get('/api/timetable', async (req, res) => res.json(await client.timetable(stopId(req))));
|
||||
app.get('/api/timetable', async (req, res) => res.json(await req.boardClient.timetable(stopId(req))));
|
||||
app.get('/api/weather', async (req, res) => {
|
||||
const id = stopId(req);
|
||||
res.json(await weatherClient.weather(client.config().cityId, id, await client.coordinates(id)));
|
||||
res.json(await weatherClient.weather(req.boardClient.config().cityId, id, await req.boardClient.coordinates(id)));
|
||||
});
|
||||
app.use('/api', (_req, res) => res.status(404).json({ error: 'not_found' }));
|
||||
// Never return upstream bodies, stack traces or API credentials.
|
||||
app.use((error, _req, res, _next) => { res.status(error instanceof ApiError ? error.status : 502).json({ error: error instanceof ApiError ? error.code : 'data_unavailable' }); });
|
||||
app.use((error, _req, res, _next) => { res.status(error.type === 'entity.parse.failed' ? 400 : error.type === 'entity.too.large' ? 413 : error instanceof ApiError ? error.status : 502).json({ error: ['entity.parse.failed', 'entity.too.large'].includes(error.type) ? 'invalid_request' : error instanceof ApiError ? error.code : 'data_unavailable' }); });
|
||||
return app;
|
||||
}
|
||||
|
||||
|
||||
@@ -33,7 +33,9 @@ function normalizeSchedules(raw, route, rawStops, stopId) {
|
||||
return [...new Set(value.map(Number))];
|
||||
};
|
||||
const terminal = end || [...rawStops].sort((a, b) => Number(b.sequence) - Number(a.sequence))[0];
|
||||
const origin = start || [...rawStops].sort((a, b) => Number(a.sequence) - Number(b.sequence))[0];
|
||||
return [{ arrivalSeconds: Number(time[1]) * 3600 + Number(time[2]) * 60 + Number(time[3] || 0) + offset * 60,
|
||||
originStopId: Number(origin?.stop_id), originTime: `${time[1].padStart(2, '0')}:${time[2]}`,
|
||||
destination: terminal?.name?.trim() || route.name.split(/\s+[-–—]\s+/).at(-1) || route.name,
|
||||
note: typeof trip.note === 'string' ? trip.note.trim() : '', daysOfWeek: days(trip.daysOfWeek), exceptDaysOfWeek: days(trip.exceptDaysOfWeek) }];
|
||||
});
|
||||
|
||||
155
src/App.tsx
@@ -1,23 +1,29 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { flushSync } from 'react-dom';
|
||||
import { ArrowDown, ArrowRight, ArrowUpRight, BusFront, Clock, Fullscreen, Info, Pause, Play, Refresh, SettingsIcon, Shield, WifiOff } from './Icons';
|
||||
import { ArrowDown, ArrowRight, ArrowUpRight, BusFront, Clock, Close, Fullscreen, Info, Pause, Play, Refresh, SettingsIcon, Shield, WifiOff } from './Icons';
|
||||
import { NumericText } from './NumericText';
|
||||
import { RouteBadge } from './RouteBadge';
|
||||
import { RouteCloud } from './RouteCloud';
|
||||
import { ServiceWindow } from './ServiceWindow';
|
||||
import { AlertCard } from './AlertCard';
|
||||
import { FittedDestination } from './FittedDestination';
|
||||
import { useIdleControls } from './useIdleControls';
|
||||
import { PassengerQr } from './PassengerQr';
|
||||
import { WeatherCard, WeatherSummary } from './WeatherBlock';
|
||||
import { localDate, timetableAvailable, timetableDepartures } from './lib/timetable.mjs';
|
||||
import { localDate, shiftDate, timetableAvailable, timetableDepartures } from './lib/timetable.mjs';
|
||||
import { boardScenes, informationBlocks, serviceWindow } from './lib/information';
|
||||
import { displayDepartures, firstAvailable, longWait, nextSameRoute, readability } from './lib/departures';
|
||||
import type { DisplayDeparture } from './lib/departures';
|
||||
import logo from './assets/ridebus-logo.svg?raw';
|
||||
import { colorScheme, departurePage, durationParts, stopDirection } from './lib/presentation.mjs';
|
||||
import type { CSSProperties } from 'react';
|
||||
import type { ComponentProps, CSSProperties } from 'react';
|
||||
import type { Alert, BoardData, Config, Departure, Scene, Settings } from './types';
|
||||
import { activeAlerts, isFresh, minutesUntil, shouldHoldScene, upcomingDepartures } from './lib/board.mjs';
|
||||
import { hasSavedSettings, readSettings, saveSettings } from './lib/storage';
|
||||
import { useBoard, useClock, useTimetable, useWeather, fallbackConfig } from './hooks';
|
||||
import { SettingsPanel } from './SettingsPanel';
|
||||
import { useDeviceControl } from './useDeviceControl';
|
||||
import type { ManagedState } from './useDeviceControl';
|
||||
import { DeviceSetup, ManagedPanel } from './DeviceSetup';
|
||||
|
||||
const clockFormat = new Intl.DateTimeFormat('ru', { timeZone: 'Europe/Minsk', hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
const dateFormat = new Intl.DateTimeFormat('ru', { timeZone: 'Europe/Minsk', weekday: 'long', day: 'numeric', month: 'long' });
|
||||
@@ -31,19 +37,29 @@ function Countdown({ departure, now, large = false }: { departure: Departure; no
|
||||
{minutes > 0 ? <span className="countdown-value">{parts.map(part => <span className="countdown-part" key={part.unit}><NumericText value={part.value} /><small><NumericText value={part.unit} /></small></span>)}</span> : <span className="countdown-now">сейчас</span>}
|
||||
</div>;
|
||||
}
|
||||
function ScheduledTime({ departure, now, className = '' }: { departure: Departure; now: number; className?: string }) {
|
||||
return <time className={className} dateTime={departure.scheduledAt}><NumericText value={departure.scheduledTime} />{localDate(Date.parse(departure.scheduledAt)) > localDate(now) && <span className="departure-day">завтра</span>}</time>;
|
||||
function ScheduledTime({ departure, now, className = '', showDay = true }: { departure: Departure; now: number; className?: string; showDay?: boolean }) {
|
||||
return <time className={className} dateTime={departure.scheduledAt}><NumericText value={departure.scheduledTime} />{showDay && localDate(Date.parse(departure.scheduledAt)) > localDate(now) && <span className="departure-day">завтра</span>}</time>;
|
||||
}
|
||||
function DepartureStatus({ departure, now, large = false }: { departure: DisplayDeparture; now: number; large?: boolean }) {
|
||||
return <div className={`departure-status ${large ? 'departure-status-large' : ''}`}>
|
||||
{departure.status === 'cancelled' ? <span className="cancelled-label"><Close size={22} />Отменён</span> : <Countdown departure={departure} now={now} large={large} />}
|
||||
{departure.status === 'notice' && <span className="departure-change">Есть изменения</span>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function DeparturesScreen({ data, departures, now, alerts, loading, rowCount, fresh, error, paused }: {
|
||||
data: BoardData | null; departures: Departure[]; now: number; alerts: Alert[]; loading: boolean; rowCount: number; fresh: boolean; error: string; paused: boolean;
|
||||
export function DeparturesScreen({ data, departures, now, alerts, loading, rowCount, fresh, error, paused, settings }: {
|
||||
data: BoardData | null; departures: DisplayDeparture[]; now: number; alerts: Alert[]; loading: boolean; rowCount: number; fresh: boolean; error: string; paused: boolean; settings: Settings;
|
||||
}) {
|
||||
const first = departures[0];
|
||||
const first = firstAvailable(departures);
|
||||
const next = settings.showNextSameRoute ? nextSameRoute(departures, first) : undefined;
|
||||
const waiting = longWait(settings, first, now);
|
||||
const reading = readability(settings);
|
||||
const table = useRef<HTMLDivElement>(null);
|
||||
const [tableHeight, setTableHeight] = useState(360);
|
||||
const [page, setPage] = useState(0);
|
||||
const capacity = Math.max(2, Math.floor(tableHeight / 62));
|
||||
const visible: { items: Departure[]; start: number; count: number; pages: number; size: number } = departurePage(departures.slice(1), rowCount, capacity, page);
|
||||
const capacity = Math.max(1, Math.floor(tableHeight / reading.rowHeight));
|
||||
const remaining = departures.filter(departure => departure.id !== first?.id);
|
||||
const visible: { items: DisplayDeparture[]; start: number; count: number; pages: number; size: number } = departurePage(remaining, rowCount, capacity, page);
|
||||
const rows = Math.max(1, Math.min(rowCount, visible.size));
|
||||
useLayoutEffect(() => {
|
||||
if (!table.current) return;
|
||||
@@ -53,46 +69,50 @@ function DeparturesScreen({ data, departures, now, alerts, loading, rowCount, fr
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (paused || visible.pages < 2) return;
|
||||
const timer = setInterval(() => setPage(value => value + 1), 7000);
|
||||
const timer = setInterval(() => setPage(value => value + 1), reading.calm ? 10000 : 7000);
|
||||
return () => clearInterval(timer);
|
||||
}, [paused, visible.pages]);
|
||||
}, [paused, visible.pages, reading.calm]);
|
||||
return <div className="departure-layout">
|
||||
<section className="departure-content" aria-label="Ближайшие рейсы">
|
||||
<section className="departure-content" data-long-wait={waiting} data-next={Boolean(next)} aria-label="Ближайшие рейсы">
|
||||
<div className="section-heading"><span className="eyebrow">БЛИЖАЙШИЙ РЕЙС</span><span className="source-label"><Clock /> По расписанию{data?.mode === 'demo' ? ' · демо' : ''}</span></div>
|
||||
{first ? <div className="hero-departure">
|
||||
{first ? <div className="hero-departure" data-long-wait={waiting}>
|
||||
<RouteBadge number={first.routeNumber} large />
|
||||
<div className="hero-destination"><div className="transport-kind"><BusFront size={19} /> {first.type === 'minibus' ? 'Маршрутное такси' : 'Автобус'}</div><FittedDestination text={first.destination} as="h2" />
|
||||
<div className="hero-details"><span>{first.note || first.routeName}</span><ScheduledTime departure={first} now={now} className="schedule-time" /></div></div>
|
||||
<Countdown departure={first} now={now} large />
|
||||
</div> : <div className="empty-departures"><BusFront size={46} /><h2>{loading ? 'Получаем расписание' : !data ? 'Расписание недоступно' : fresh ? 'На сегодня рейсов больше нет' : 'Сохранённые рейсы закончились'}</h2>
|
||||
<p>{loading ? 'Ближайший транспорт появится здесь' : error === 'api_not_configured' ? 'Подключите RideBus API или включите деморежим в настройках' : !data || !fresh ? 'Ожидаем подключения к RideBus' : 'Новые рейсы появятся с началом движения'}</p></div>}
|
||||
<div className="next-heading"><span className="eyebrow">СЛЕДОМ ПРИБУДУТ</span>{visible.pages > 1 ? <span className="page-indicator">{visible.start + 1}–{visible.start + visible.items.length} из {visible.count}</span> : <ArrowDown size={22} />}</div>
|
||||
{!waiting && <div className="hero-details"><span>{first.note || first.routeName}</span><ScheduledTime departure={first} now={now} className="schedule-time" /></div>}
|
||||
{next && <div className="hero-next"><span>Следующий №{first.routeNumber}</span><ScheduledTime departure={next} now={now} /></div>}
|
||||
</div>
|
||||
{waiting ? <div className="long-wait-time"><span>{localDate(Date.parse(first.scheduledAt)) > localDate(now) ? 'Завтра в' : 'Сегодня в'}</span><ScheduledTime departure={first} now={now} showDay={false} />{first.status === 'notice' && <span className="departure-change">Есть изменения</span>}</div> : <DepartureStatus departure={first} now={now} large />}
|
||||
</div> : <div className="empty-departures" data-cancelled={departures.length > 0}><BusFront size={46} /><h2>{departures.length ? 'Ближайшие рейсы отменены' : loading ? 'Получаем расписание' : !data ? 'Расписание недоступно' : fresh ? 'На сегодня рейсов больше нет' : 'Сохранённые рейсы закончились'}</h2>
|
||||
<p>{departures.length ? 'Подробности — в информации о движении' : loading ? 'Ближайший транспорт появится здесь' : error === 'api_not_configured' ? 'Подключите RideBus API или включите деморежим в настройках' : !data || !fresh ? 'Ожидаем подключения к RideBus' : 'Новые рейсы появятся с началом движения'}</p></div>}
|
||||
<div className="next-heading"><span className="eyebrow">{!first && departures.length ? 'ОТМЕНЁННЫЕ РЕЙСЫ' : remaining.some(item => item.status === 'cancelled') ? 'ДРУГИЕ РЕЙСЫ' : 'СЛЕДОМ ПРИБУДУТ'}</span>{visible.pages > 1 ? <span className="page-indicator">{visible.start + 1}{visible.items.length > 1 ? `–${visible.start + visible.items.length}` : ''} из {visible.count}</span> : <ArrowDown size={22} />}</div>
|
||||
<div ref={table} className="departure-table" data-rows={rowCount} data-paged={visible.pages > 1} style={{ '--rows': rows, '--row-height': `${tableHeight / rows}px` } as CSSProperties}>
|
||||
{visible.items.map((departure, index) => <article className="departure-row" key={`position-${index}`}>
|
||||
{visible.items.map((departure, index) => <article className="departure-row" data-status={departure.status} key={`position-${index}`}>
|
||||
<RouteBadge number={departure.routeNumber} />
|
||||
<div className="row-destination"><FittedDestination text={departure.destination} rowHeight={tableHeight / rows} /><p>{departure.note || (departure.type === 'minibus' ? 'Маршрутное такси' : 'Автобус')}</p><ScheduledTime departure={departure} now={now} className="row-time-compact" /></div>
|
||||
<ScheduledTime departure={departure} now={now} className="row-time" />
|
||||
<Countdown departure={departure} now={now} />
|
||||
<DepartureStatus departure={departure} now={now} />
|
||||
</article>)}
|
||||
{first && departures.length === 1 && <p className="empty-following">Это последний доступный рейс</p>}
|
||||
{!first && <div className="quiet-placeholder"><span /><span /><span /></div>}
|
||||
{!departures.length && <div className="quiet-placeholder"><span /><span /><span /></div>}
|
||||
</div>
|
||||
</section>
|
||||
{alerts[0] && <div className="transport-notice"><Info /><span>{alerts[0].routeNumbers.map(number => `№${number}`).join(' · ')} {alerts[0].title}</span></div>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function InfoScreen({ data, alerts, fresh, seconds, paused, page, onPage, config, weather, now }: { data: BoardData | null; alerts: Alert[]; fresh: boolean; seconds: number; paused: boolean; page: number; onPage: (page: number) => void;
|
||||
config: Config; weather: ReturnType<typeof useWeather>; now: number }) {
|
||||
function InfoScreen({ data, alerts, fresh, seconds, paused, page, onPage, config, weather, now, settings, service }: { data: BoardData | null; alerts: Alert[]; fresh: boolean; seconds: number; paused: boolean; page: number; onPage: (page: number) => void;
|
||||
config: Config; weather: ReturnType<typeof useWeather>; now: number; settings: Settings; service: ComponentProps<typeof ServiceWindow> }) {
|
||||
const panel = useRef<HTMLElement>(null);
|
||||
const grid = useRef<HTMLDivElement>(null);
|
||||
const [compact, setCompact] = useState(false);
|
||||
const blocks = informationBlocks(settings);
|
||||
const activeBlock = blocks[page % blocks.length]?.id;
|
||||
useLayoutEffect(() => {
|
||||
if (!grid.current) return;
|
||||
const observer = new ResizeObserver(entries => { const { width, height } = entries[0].contentRect; setCompact(width < 1000 || innerHeight > innerWidth || innerHeight < 650 || height < 330); });
|
||||
const observer = new ResizeObserver(entries => { const { width, height } = entries[0].contentRect; setCompact(width < 1000 || innerHeight > innerWidth || innerHeight < 650 || height < 330 || settings.displayProfile === 'street' || settings.viewingDistance === 'far'); });
|
||||
observer.observe(grid.current);
|
||||
return () => observer.disconnect();
|
||||
}, [alerts.length]);
|
||||
}, [alerts.length, blocks.length, settings.displayProfile, settings.viewingDistance]);
|
||||
useEffect(() => {
|
||||
const element = panel.current;
|
||||
if (!element || paused || matchMedia('(prefers-reduced-motion: reduce)').matches) return;
|
||||
@@ -103,25 +123,37 @@ function InfoScreen({ data, alerts, fresh, seconds, paused, page, onPage, config
|
||||
}, 3500);
|
||||
return () => { clearTimeout(start); clearInterval(timer); };
|
||||
}, [seconds, paused, page]);
|
||||
return <section ref={panel} className="info-screen" data-compact={compact} data-cards={!alerts.length} aria-label="Информация для пассажиров">
|
||||
return <section ref={panel} className="info-screen" data-compact={compact} data-cards={!alerts.length} data-blocks={blocks.length}
|
||||
style={{ '--info-columns': blocks.length, '--info-tabs-width': `${blocks.length > 1 ? blocks.length * 100 : 0}px` } as CSSProperties} aria-label="Информация для пассажиров">
|
||||
<div className="info-intro"><h2>{alerts.length ? 'Изменения движения' : 'Информация'}</h2>
|
||||
{!alerts.length && <nav className="information-tabs" aria-label="Информация на вертикальном экране">{['Маршруты', 'Погода', 'RideBus'].map((name, index) => <button key={name} aria-current={page % 3 === index ? 'page' : undefined} onClick={() => onPage(index)}>{name}</button>)}</nav>}</div>
|
||||
{!alerts.length && blocks.length > 1 && <nav className="information-tabs" aria-label="Блоки информации">{blocks.map((block, index) => <button key={block.id} aria-current={activeBlock === block.id ? 'page' : undefined} onClick={() => onPage(index)}>{block.name}</button>)}</nav>}</div>
|
||||
{alerts.length ? <div className="info-notices">{alerts.map(alert => <AlertCard key={alert.id} alert={alert} />)}</div> : <div ref={grid} className="info-grid">
|
||||
<section className="info-card routes-info" data-active={page % 3 === 0}><h3>Маршруты</h3><RouteCloud routes={data?.routes || []} paused={paused} /></section>
|
||||
<section className="info-card weather-info" data-active={page % 3 === 1}><WeatherCard data={weather.data} now={now} loading={weather.loading} failed={weather.failed} demo={data?.mode === 'demo'} /></section>
|
||||
<section className="info-card qr-info" data-active={page % 3 === 2}><PassengerQr config={config} stop={data?.stop} /></section>
|
||||
{blocks.map(block => <section key={block.id} className={`info-card ${block.id}-info`} data-active={activeBlock === block.id}>
|
||||
{block.id === 'service' && <ServiceWindow {...service} />}
|
||||
{block.id === 'weather' && <WeatherCard data={weather.data} now={now} loading={weather.loading} failed={weather.failed} demo={data?.mode === 'demo'} />}
|
||||
{block.id === 'qr' && <PassengerQr config={config} stop={data?.stop} />}
|
||||
</section>)}
|
||||
</div>}
|
||||
{!alerts.length && (data?.alertsAvailable === false || !fresh) && <div className="info-status"><Shield /><span>Предупреждения сейчас недоступны</span></div>}
|
||||
</section>;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const remote = useDeviceControl();
|
||||
if (remote.control.state === 'standalone') return <BoardApplication />;
|
||||
if (remote.managed) return <BoardApplication key={`${remote.managed.device.id}:${remote.managed.config.cityId}`} management={{ state: remote.managed, failed: remote.failed, onApplied: remote.onApplied }} />;
|
||||
return <DeviceSetup control={remote.control} failed={remote.failed} onRetry={remote.retry} />;
|
||||
}
|
||||
|
||||
function BoardApplication({ management }: { management?: { state: ManagedState; failed: boolean; onApplied: (revision: number) => void } }) {
|
||||
const [settings, setSettings] = useState<Settings>(() => {
|
||||
if (management) return management.state.device.settings;
|
||||
const saved = readSettings();
|
||||
if (new URLSearchParams(location.search).get('demo') === '1') saved.demo = true;
|
||||
return saved;
|
||||
});
|
||||
const [config, setConfig] = useState(fallbackConfig);
|
||||
const [config, setConfig] = useState(management?.state.config || fallbackConfig);
|
||||
const acknowledged = useRef(0);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const controlsVisible = useIdleControls(settingsOpen);
|
||||
const [scene, setScene] = useState<Scene>('departures');
|
||||
@@ -138,25 +170,38 @@ export default function App() {
|
||||
const fullDepartures = useMemo(() => timetableDepartures(timetable.data, date) as Departure[], [timetable.data, date]);
|
||||
const fullAvailable = !settings.demo && timetableAvailable(timetable.data, now);
|
||||
const data: BoardData | null = liveData || (fullAvailable && timetable.data ? { ...timetable.data, departures: fullDepartures, alerts: [], alertsAvailable: false, partial: false, serviceDate: date, mode: 'live' } : null);
|
||||
const departures: Departure[] = fullAvailable ? fullDepartures.filter(item => Date.parse(item.scheduledAt) + 60000 > now && (!settings.routeFilter || String(item.routeId) === settings.routeFilter)) : upcomingDepartures(data, now, settings.routeFilter);
|
||||
const rawDepartures: Departure[] = fullAvailable ? fullDepartures.filter(item => Date.parse(item.scheduledAt) + 60000 > now && (!settings.routeFilter || String(item.routeId) === settings.routeFilter)) : upcomingDepartures(data, now, settings.routeFilter);
|
||||
const alerts: Alert[] = activeAlerts(data?.alerts || [], now, settings.routeFilter, data?.routes || []);
|
||||
const fresh = settings.demo || isFresh(data, now, failed);
|
||||
const held = shouldHoldScene(settings, departures[0], now);
|
||||
const reliableAlerts = fresh && data?.alertsAvailable === true;
|
||||
// Resolve ambiguous cancellations before applying the operator's route filter.
|
||||
const allDepartures = displayDepartures(fullAvailable ? fullDepartures : data?.departures || [], alerts, settings.stopId, now, reliableAlerts);
|
||||
const statusById = new Map(allDepartures.map(item => [item.id, item]));
|
||||
const departures = rawDepartures.map(item => statusById.get(item.id)!);
|
||||
const first = firstAvailable(departures);
|
||||
const service = serviceWindow(allDepartures.filter(item => item.status !== 'cancelled'), now, settings.routeFilter);
|
||||
const reading = readability(settings);
|
||||
const held = shouldHoldScene(settings, first, now);
|
||||
const urgent = alerts.some(alert => alert.type === 'cancellation' || alert.type === 'detour');
|
||||
const scenes: Scene[] = settings.adsEnabled && !urgent ? ['departures', 'info', 'ad'] : ['departures', 'info'];
|
||||
const hasAlerts = alerts.length > 0;
|
||||
const scenes = useMemo(() => boardScenes(settings, hasAlerts, urgent), [settings, hasAlerts, urgent]);
|
||||
const seconds = scene === 'departures' ? settings.mainSeconds : scene === 'info' ? Math.max(settings.infoSeconds, Math.min(60, alerts.reduce((sum, alert) => sum + alert.message.length, 0) / 18)) : settings.adSeconds;
|
||||
const elapsed = Date.now() - startedAt;
|
||||
const progress = Math.min(1, elapsed / (seconds * 1000));
|
||||
const sceneState = useRef({ scenes, scene, held, settingsOpen, paused });
|
||||
sceneState.current = { scenes, scene, held, settingsOpen, paused };
|
||||
const sceneState = useRef({ scenes, scene, held, settingsOpen, paused, calm: reading.calm });
|
||||
sceneState.current = { scenes, scene, held, settingsOpen, paused, calm: reading.calm };
|
||||
const showNotice = useCallback((text: string) => {
|
||||
setNotice(text); if (noticeTimer.current) clearTimeout(noticeTimer.current);
|
||||
noticeTimer.current = setTimeout(() => setNotice(''), 4000);
|
||||
}, []);
|
||||
const goTo = useCallback((next: Scene) => {
|
||||
if (!sceneState.current.scenes.includes(next)) return;
|
||||
if (sceneState.current.held && next !== 'departures') { showNotice('Главный экран удерживается: до ближайшего рейса не больше 2 минут'); return; }
|
||||
const change = () => { if (sceneState.current.scene === 'info' && next !== 'info') setInfoPage(value => value + 1); setScene(next); setStartedAt(Date.now()); };
|
||||
if (document.startViewTransition && !matchMedia('(prefers-reduced-motion: reduce)').matches) document.startViewTransition(() => flushSync(change));
|
||||
if (document.startViewTransition && !sceneState.current.calm && !matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
const transition = document.startViewTransition(() => flushSync(change));
|
||||
void transition.ready.catch(() => {}); // Resizing can interrupt the animation after the scene has changed.
|
||||
}
|
||||
else change();
|
||||
}, [showNotice]);
|
||||
const nextScene = useCallback(() => {
|
||||
@@ -168,6 +213,7 @@ export default function App() {
|
||||
catch { showNotice('Полный экран недоступен в этом браузере'); }
|
||||
}, [showNotice]);
|
||||
useEffect(() => {
|
||||
if (management) return;
|
||||
const controller = new AbortController();
|
||||
fetch('/api/config', { signal: controller.signal }).then(response => { if (!response.ok) throw new Error(); return response.json(); }).then((value: Config) => {
|
||||
setConfig(value);
|
||||
@@ -175,16 +221,27 @@ export default function App() {
|
||||
}).catch(() => {});
|
||||
return () => controller.abort();
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (!management) return;
|
||||
setSettings(management.state.device.settings); setConfig(management.state.config);
|
||||
setSettingsOpen(false); setScene('departures'); setInfoPage(0); setStartedAt(Date.now());
|
||||
}, [management?.state.device.revision]);
|
||||
useEffect(() => {
|
||||
if (management && settings === management.state.device.settings && acknowledged.current !== management.state.device.revision) {
|
||||
acknowledged.current = management.state.device.revision;
|
||||
management.onApplied(management.state.device.revision);
|
||||
}
|
||||
}, [management, settings]);
|
||||
useEffect(() => { document.title = `${data?.stop.name || config.stopName} · RideBus Board`; }, [data?.stop.name, config.stopName]);
|
||||
useEffect(() => {
|
||||
if (held) { setScene('departures'); setStartedAt(Date.now()); }
|
||||
else setStartedAt(Date.now());
|
||||
}, [held]);
|
||||
useEffect(() => { if (urgent && scene === 'ad') { setScene('info'); setStartedAt(Date.now()); } }, [urgent, scene]);
|
||||
useEffect(() => { if (!scenes.includes(scene)) { setScene(urgent ? 'info' : 'departures'); setStartedAt(Date.now()); } }, [scenes, urgent, scene]);
|
||||
useEffect(() => {
|
||||
if (paused || settingsOpen) { setStartedAt(Date.now()); return; }
|
||||
if (!held && elapsed >= seconds * 1000) nextScene();
|
||||
}, [now, held, paused, settingsOpen, elapsed, seconds, nextScene]);
|
||||
if (scenes.length > 1 && !held && elapsed >= seconds * 1000) nextScene();
|
||||
}, [now, held, paused, settingsOpen, elapsed, seconds, nextScene, scenes.length]);
|
||||
useEffect(() => {
|
||||
function keydown(event: KeyboardEvent) {
|
||||
if (event.target instanceof HTMLElement && (['INPUT', 'SELECT', 'TEXTAREA', 'BUTTON'].includes(event.target.tagName) || event.target.isContentEditable)) return;
|
||||
@@ -202,24 +259,26 @@ export default function App() {
|
||||
const timeParts = clockFormat.format(now).split(':');
|
||||
const updateLabel = fullAvailable && timetable.data ? timeFormat.format(Date.parse(timetable.data.updatedAt)) : data ? timeFormat.format(Date.parse(data.updatedAt)) : '—';
|
||||
const direction = stopDirection(data?.stop);
|
||||
return <div className={`board ${scene !== 'departures' ? 'board-secondary' : ''} ${controlsVisible ? '' : 'controls-idle'}`} data-theme={settings.theme} data-row-count={settings.rowCount} style={{ '--accent': colorScheme(settings.theme === 'light' ? settings.lightColorScheme : settings.colorScheme, settings.theme).color } as CSSProperties}>
|
||||
return <div className={`board ${scene !== 'departures' ? 'board-secondary' : ''} ${controlsVisible ? '' : 'controls-idle'}`} data-theme={settings.theme} data-row-count={settings.rowCount} data-profile={settings.displayProfile} data-distance={settings.viewingDistance} data-motion={reading.calm ? 'calm' : 'full'} style={{ '--reading-scale': reading.scale, '--accent': colorScheme(settings.theme === 'light' ? settings.lightColorScheme : settings.colorScheme, settings.theme).color } as CSSProperties}>
|
||||
<header className="board-header"><div className="stop-heading"><h1>{data?.stop.name || config.stopName}</h1>
|
||||
<div className="stop-meta">{direction && <div className="stop-direction"><ArrowRight /><span>{direction}</span></div>}<WeatherSummary data={weather.data} now={now} failed={weather.failed} /></div></div>
|
||||
<div className="header-right"><div className="brand" aria-label="RideBus"><span className="official-mark" aria-hidden="true" dangerouslySetInnerHTML={{ __html: logo }} /><span className="brand-word">ridebus</span></div><div className="clock" aria-label={`Текущее время ${clockFormat.format(now)}`}><NumericText value={`${timeParts[0]}:${timeParts[1]}`} /><small><NumericText value={timeParts[2]} /></small></div><div className="today">{dateFormat.format(now)}</div></div>
|
||||
</header>
|
||||
{!fresh && !settings.demo && <div className="offline-banner" role="status"><WifiOff /><span>{data ? `Нет свежих данных. Сохранённое расписание · обновлено в ${updateLabel}` : loading ? 'Подключаемся к RideBus…' : 'Не удалось загрузить расписание. Проверьте подключение.'}</span><button onClick={refresh} aria-label="Повторить подключение"><Refresh /></button></div>}
|
||||
{management?.failed && <div className="managed-offline" role="status">Нет связи с управлением · сохранённые настройки</div>}
|
||||
<main className="scene-container"><div className="scene" key={scene} data-scene={scene}>
|
||||
{scene === 'departures' && <DeparturesScreen data={data} departures={departures} now={now} alerts={alerts} loading={loading} rowCount={settings.rowCount} fresh={fresh} error={error} paused={paused || settingsOpen} />}
|
||||
{scene === 'info' && <InfoScreen data={data} alerts={alerts} fresh={fresh} seconds={seconds} paused={paused || settingsOpen} page={infoPage} onPage={setInfoPage} config={config} weather={weather} now={now} />}
|
||||
{scene === 'departures' && <DeparturesScreen data={data} departures={departures} now={now} alerts={alerts} loading={loading} rowCount={settings.rowCount} fresh={fresh} error={error} paused={paused || settingsOpen} settings={settings} />}
|
||||
{scene === 'info' && <InfoScreen data={data} alerts={alerts} fresh={fresh} seconds={seconds} paused={paused || settingsOpen} page={infoPage} onPage={setInfoPage} config={config} weather={weather} now={now} settings={settings}
|
||||
service={{ ...service, available: fullAvailable, tomorrowAvailable: Boolean(fullAvailable && timetable.data?.calendarYears.includes(Number(shiftDate(date, 1).slice(0, 4)))), loading: timetable.loading, now }} />}
|
||||
{scene === 'ad' && <section className="ad-screen"><span className="eyebrow">РЕКЛАМА · ПРЕДПРОСМОТР</span><h2>{settings.adTitle}</h2><p>{settings.adText}</p><div className="ad-signature">Время для нового.<ArrowUpRight /></div></section>}
|
||||
</div></main>
|
||||
{scene !== 'departures' && <div className="persistent-next"><span className="eyebrow">БЛИЖАЙШИЙ РЕЙС</span>{departures[0] ? <><RouteBadge number={departures[0].routeNumber} filled /><div className="persistent-destination"><strong>{departures[0].destination}</strong><ScheduledTime departure={departures[0]} now={now} /></div><Countdown departure={departures[0]} now={now} /></> : <span>Нет доступных рейсов</span>}</div>}
|
||||
<footer className="board-footer"><div className="cycle-progress"><span style={{ transform: `scaleX(${held || paused || settingsOpen ? 0 : progress})` }} /></div>
|
||||
{scene !== 'departures' && <div className="persistent-next"><span className="eyebrow">БЛИЖАЙШИЙ РЕЙС</span>{first ? <><RouteBadge number={first.routeNumber} filled /><div className="persistent-destination"><strong>{first.destination}</strong><ScheduledTime departure={first} now={now} /></div><Countdown departure={first} now={now} /></> : <span>Нет доступных рейсов</span>}</div>}
|
||||
<footer className="board-footer"><div className="cycle-progress"><span style={{ transform: `scaleX(${held || paused || settingsOpen || scenes.length < 2 ? 0 : progress})` }} /></div>
|
||||
<div className="footer-main"><nav className="scene-navigation" aria-label="Экраны табло">{scenes.map((item, index) => <button key={item} onClick={() => goTo(item)} className={scene === item ? 'active' : ''} aria-label={`0${index + 1} ${sceneNames[item]}`} title={sceneNames[item]} aria-current={scene === item ? 'page' : undefined}><span className="scene-index">0{index + 1}</span><span>{sceneNames[item]}</span></button>)}</nav>
|
||||
<div className="footer-status">{!fresh && !settings.demo && <WifiOff />}<span>{settings.demo ? 'Демонстрация' : held ? 'Приоритет транспорта' : `Обновлено в ${updateLabel}`}{data?.partial ? ' · часть рейсов недоступна' : ''}{offlineSaved === false ? ' · сохранение недоступно' : ''}</span></div>
|
||||
<div className="board-controls"><button className="icon-button" onClick={() => { setPaused(value => !value); setStartedAt(Date.now()); }} aria-label={paused ? 'Продолжить смену экранов' : 'Приостановить смену экранов'} title="Пауза · пробел">{paused ? <Play /> : <Pause />}</button><button className="icon-button" onClick={nextScene} aria-label="Следующий экран" title="Следующий экран · →"><ArrowRight /></button><button className="icon-button" onClick={() => void toggleFullscreen()} aria-label={fullscreen ? 'Выйти из полного экрана' : 'На весь экран'} title="Полный экран · F"><Fullscreen /></button><button className="icon-button" onClick={() => setSettingsOpen(true)} aria-label="Настройки табло" title="Настройки · S"><SettingsIcon /></button></div></div>
|
||||
</footer>
|
||||
{notice && <div className="toast" role="status">{notice}</div>}
|
||||
{settingsOpen && <SettingsPanel settings={settings} config={config} data={data} onClose={() => { setSettingsOpen(false); setStartedAt(Date.now()); }} onFullscreen={() => void toggleFullscreen()} onSave={value => { setSettings(value); saveSettings(value); setScene('departures'); setStartedAt(Date.now()); setSettingsOpen(false); showNotice('Настройки сохранены'); }} />}
|
||||
{settingsOpen && (management ? <ManagedPanel state={management.state} failed={management.failed} onClose={() => setSettingsOpen(false)} onFullscreen={() => void toggleFullscreen()} /> : <SettingsPanel settings={settings} config={config} data={data} onClose={() => { setSettingsOpen(false); setStartedAt(Date.now()); }} onFullscreen={() => void toggleFullscreen()} onSave={value => { setSettings(value); saveSettings(value); setScene('departures'); setInfoPage(0); setStartedAt(Date.now()); setSettingsOpen(false); showNotice('Настройки сохранены'); }} />)}
|
||||
</div>;
|
||||
}
|
||||
|
||||
23
src/DeviceSetup.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import logo from './assets/ridebus-logo.svg?raw';
|
||||
import { NumericText } from './NumericText';
|
||||
import { Fullscreen, Refresh, Shield } from './Icons';
|
||||
import type { ManagedState } from './useDeviceControl';
|
||||
export function DeviceSetup({ control, failed, onRetry }: { control: { state: string; code?: string; expiresAt?: string; adminUrl?: string }; failed: boolean; onRetry: () => void }) {
|
||||
const [now, setNow] = useState(Date.now());
|
||||
useEffect(() => { document.title = 'Подключение · RideBus Board'; const interval = setInterval(() => setNow(Date.now()), 1000); return () => clearInterval(interval); }, []);
|
||||
const remaining = Math.max(0, Math.min(300, Math.ceil((Date.parse(control.expiresAt || '') - now) / 1000)));
|
||||
const pending = control.state === 'pending' && remaining > 0;
|
||||
return <main className="device-setup" aria-label="Подключение табло"><div className="device-setup-brand"><span dangerouslySetInnerHTML={{ __html: logo }} />ridebus</div><div className="device-setup-card"><span className="eyebrow">RIDEBUS BOARD</span><h1>{control.state === 'revoked' ? 'Устройство отвязано' : 'Подключите табло'}</h1>
|
||||
<p>{pending ? 'Введите этот код в админке: Устройства → Добавить устройство' : control.state === 'revoked' ? 'Для повторной настройки подключите устройство по новому коду' : control.state === 'storage-error' ? 'Разрешите браузеру сохранять cookies и данные сайта' : control.state === 'loading' ? 'Получаем код подключения…' : 'Для подключения нужен доступ к серверу'}</p>
|
||||
{pending && <><div className="device-pair-code" aria-label={`Код подключения ${control.code}`}><NumericText value={control.code || ''} /></div><div className="device-pair-expiry">Код действует ещё <NumericText value={`${Math.floor(remaining / 60)}:${String(remaining % 60).padStart(2, '0')}`} /></div></>}
|
||||
{control.adminUrl && <a href={control.adminUrl} target="_blank" rel="noreferrer">Открыть админку</a>}
|
||||
{failed && <div className="device-setup-status" role="status">{control.state === 'storage-error' ? 'Не удалось сохранить привязку устройства' : 'Нет связи с сервером. Повторяем подключение'}</div>}
|
||||
{!pending && control.state !== 'loading' && <button onClick={onRetry}><Refresh />{control.state === 'revoked' ? 'Подключить заново' : 'Повторить'}</button>}
|
||||
</div><button className="device-setup-fullscreen" onClick={() => { void document.documentElement.requestFullscreen().catch(() => {}); }}><Fullscreen />На весь экран</button></main>;
|
||||
}
|
||||
export function ManagedPanel({ state, failed, onClose, onFullscreen }: { state: ManagedState; failed: boolean; onClose: () => void; onFullscreen: () => void }) {
|
||||
const dialog = useRef<HTMLDialogElement>(null);
|
||||
useEffect(() => { dialog.current?.showModal(); }, []);
|
||||
return <dialog ref={dialog} className="managed-panel" aria-labelledby="managed-title" onCancel={event => { event.preventDefault(); onClose(); }}><Shield size={36} /><h2 id="managed-title">Управляется из админки</h2><strong>{state.device.name}</strong><p>Изменяйте остановку, оформление и экраны в разделе «Устройства».</p><dl><dt>Настройки</dt><dd>Версия {state.device.revision}</dd><dt>Соединение</dt><dd>{failed ? 'Нет связи · сохранённые настройки' : 'Подключено'}</dd></dl><div><button onClick={onFullscreen}><Fullscreen />Полный экран</button><button onClick={onClose}>Закрыть</button></div></dialog>;
|
||||
}
|
||||
@@ -7,9 +7,17 @@ export function FittedDestination({ text, rowHeight, as: Tag = 'h3' }: { text: s
|
||||
useLayoutEffect(() => {
|
||||
const element = heading.current, container = element?.parentElement;
|
||||
if (!element || !container) return;
|
||||
const context = document.createElement('canvas').getContext('2d');
|
||||
const fit = () => {
|
||||
element.style.fontSize = '';
|
||||
const base = parseFloat(getComputedStyle(element).fontSize);
|
||||
const headingCss = getComputedStyle(element);
|
||||
let base = parseFloat(headingCss.fontSize);
|
||||
if (context && element.clientWidth > 0) {
|
||||
context.font = `${headingCss.fontStyle} ${headingCss.fontWeight} ${headingCss.fontSize} ${headingCss.fontFamily}`;
|
||||
const longestWord = Math.max(1, ...text.split(/\s+/).map(word => context.measureText(word).width));
|
||||
base = Math.max(Math.min(16, base), Math.min(base, base * element.clientWidth / longestWord));
|
||||
element.style.fontSize = `${base}px`;
|
||||
}
|
||||
const detailsHeight = Array.from(container.children).filter(child => child !== element).reduce((height, child) => {
|
||||
const css = getComputedStyle(child);
|
||||
return height + (css.display === 'none' || css.position === 'absolute' ? 0 : child.clientHeight + parseFloat(css.marginTop) + parseFloat(css.marginBottom));
|
||||
|
||||
22
src/ServiceWindow.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { NumericText } from './NumericText';
|
||||
import { RouteBadge } from './RouteBadge';
|
||||
import type { Departure } from './types';
|
||||
|
||||
export function ServiceWindow({ lastToday, firstTomorrow, available, tomorrowAvailable, loading, now }: {
|
||||
lastToday: Departure | null; firstTomorrow: Departure | null; available: boolean;
|
||||
tomorrowAvailable: boolean; loading: boolean; now: number;
|
||||
}) {
|
||||
return <><h3>Первый и последний рейс</h3>
|
||||
{available ? <div className="service-window">
|
||||
{[
|
||||
{ label: 'Последний сегодня', departure: lastToday, empty: 'Сегодня рейсов нет', ended: Boolean(lastToday && Date.parse(lastToday.scheduledAt) + 60000 <= now) },
|
||||
{ label: 'Первый завтра', departure: firstTomorrow, empty: tomorrowAvailable ? 'Завтра рейсов нет' : 'Расписание на завтра недоступно', ended: false },
|
||||
].map(item => <div className="service-boundary" key={item.label}>
|
||||
<div className="service-label">{item.label}{item.ended && <span>время прошло</span>}</div>
|
||||
{item.departure ? <><time dateTime={item.departure.scheduledAt}><NumericText value={item.departure.scheduledTime} /></time>
|
||||
<div className="service-trip"><RouteBadge number={item.departure.routeNumber} filled /><span>{item.departure.destination}</span></div>
|
||||
</> : <p className="service-empty">{item.empty}</p>}
|
||||
</div>)}
|
||||
</div> : <p className="service-unavailable">{loading ? 'Загружаем полное расписание' : 'Полное расписание пока недоступно'}</p>}
|
||||
</>;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { ArrowDown, ArrowUp, Check, Fullscreen, Reset, Close } from './Icons';
|
||||
import { schemesForTheme } from './lib/presentation.mjs';
|
||||
import { informationBlocks, informationOptions } from './lib/information';
|
||||
import { useSettingsNavigation } from './useSettingsNavigation';
|
||||
import type { BoardData, Config, Settings, Stop } from './types';
|
||||
|
||||
@@ -83,12 +84,21 @@ export function SettingsPanel({ settings, config, data, onSave, onClose, onFulls
|
||||
<h4>Цветовая схема</h4><fieldset className="theme-picker"><legend className="sr-only">Акцент для {draft.theme === 'light' ? 'белого' : 'чёрного'} фона</legend>
|
||||
{schemesForTheme(draft.theme).map(scheme => <label className="theme-option" key={scheme.id}><input type="radio" name="color-scheme" value={scheme.id} checked={(draft.theme === 'light' ? draft.lightColorScheme : draft.colorScheme) === scheme.id} onChange={() => update(draft.theme === 'light' ? 'lightColorScheme' : 'colorScheme', scheme.id)} /><span className="theme-swatch" style={{ background: scheme.color }} /><span>{scheme.name}</span></label>)}
|
||||
</fieldset><p className="form-hint">У каждой темы своя контрастная палитра. Выбранные цвета сохраняются отдельно.</p>
|
||||
<h4>Читаемость</h4><div className="form-grid">
|
||||
<label>Профиль экрана<select value={draft.displayProfile} onChange={event => update('displayProfile', event.target.value as Settings['displayProfile'])}><option value="tv">Телевизор</option><option value="street">Уличное табло</option></select></label>
|
||||
<label>Расстояние просмотра<select value={draft.viewingDistance} onChange={event => update('viewingDistance', event.target.value as Settings['viewingDistance'])}><option value="near">Близко</option><option value="medium">Средне</option><option value="far">Далеко</option></select></label>
|
||||
</div><p className="form-hint">Уличный профиль — крупнее текст и спокойнее анимация. Для просмотра издалека меньше рейсов показывается одновременно; остальные сменяются страницами.</p>
|
||||
</section>
|
||||
<section hidden={page !== 2} data-settings-section="2" id="settings-section-2"><h3>Экраны и переходы</h3>
|
||||
<div className="form-grid"><label>Главный экран, сек<input type="number" min="5" max="120" required value={draft.mainSeconds} onChange={event => update('mainSeconds', Number(event.target.value))} /></label>
|
||||
<label>Информация, сек<input type="number" min="5" max="60" required value={draft.infoSeconds} onChange={event => update('infoSeconds', Number(event.target.value))} /></label></div>
|
||||
<label>Следующие рейсы<select value={draft.rowCount} onChange={event => update('rowCount', Number(event.target.value))}>{[2,3,4,5,6].map(number => <option key={number} value={number}>{number}</option>)}</select></label>
|
||||
<label className="switch-row"><span>Удерживать главный экран<small>Когда до рейса осталось не больше 2 минут</small></span><input type="checkbox" checked={draft.holdNear} onChange={event => update('holdNear', event.target.checked)} /></label>
|
||||
<label className="switch-row"><span>Следующий рейс того же маршрута<small>В том же направлении, под ближайшим отправлением</small></span><input type="checkbox" checked={draft.showNextSameRoute} onChange={event => update('showNextSameRoute', event.target.checked)} /></label>
|
||||
<label className="switch-row"><span>Режим длительного ожидания<small>От 90 минут — крупное время отправления вместо отсчёта</small></span><input type="checkbox" checked={draft.longWaitEnabled} onChange={event => update('longWaitEnabled', event.target.checked)} /></label>
|
||||
<h4>Блоки информации</h4>
|
||||
{informationOptions.map(block => <label className="switch-row" key={block.id}><span>{block.label}</span><input type="checkbox" checked={draft[block.setting]} onChange={event => update(block.setting, event.target.checked)} /></label>)}
|
||||
{!informationBlocks(draft).length && <p className="form-hint">Экран информации появится только при предупреждениях о движении.</p>}
|
||||
</section>
|
||||
<section hidden={page !== 3} data-settings-section="3" id="settings-section-3"><h3>Рекламный экран <span className="tiny-tag">ПРОТОТИП</span></h3>
|
||||
<label className="switch-row"><span>Добавить в цикл<small>По умолчанию отключён</small></span><input type="checkbox" checked={draft.adsEnabled} onChange={event => update('adsEnabled', event.target.checked)} /></label>
|
||||
@@ -98,7 +108,7 @@ export function SettingsPanel({ settings, config, data, onSave, onClose, onFulls
|
||||
</section>
|
||||
</div>
|
||||
<div className="settings-scroll-controls" aria-label="Прокрутка настроек"><p className="settings-navigation-hint" role="status">{navigation.editing ? 'Стрелки — изменить · OK — готово' : navigation.remote ? 'Стрелки — переход · OK — выбрать' : 'Наведите курсор и нажмите OK'}</p><button type="button" className="settings-scroll-button" disabled={!scroll.up} onClick={() => { if (body.current) body.current.scrollTop -= body.current.clientHeight * .7; }}><ArrowUp size={20} /> Выше</button><button type="button" className="settings-scroll-button" disabled={!scroll.down} onClick={() => { if (body.current) body.current.scrollTop += body.current.clientHeight * .7; }}><ArrowDown size={20} /> Ниже</button></div>
|
||||
<div className="settings-actions"><button type="button" className="text-button" onClick={() => { setValidation(''); setDraft({ ...draft, mainSeconds: 25, infoSeconds: 8, rowCount: 4, holdNear: true, adsEnabled: false }); }}><Reset size={17} /> Сбросить цикл</button><button type="submit" className="primary-button"><Check size={18} /> Сохранить</button></div>
|
||||
<div className="settings-actions"><button type="button" className="text-button" onClick={() => { setValidation(''); setDraft({ ...draft, mainSeconds: 25, infoSeconds: 8, rowCount: 4, holdNear: true, showNextSameRoute: true, longWaitEnabled: true, infoServiceEnabled: true, infoWeatherEnabled: true, infoQrEnabled: true, adsEnabled: false }); }}><Reset size={17} /> Сбросить цикл</button><button type="submit" className="primary-button"><Check size={18} /> Сохранить</button></div>
|
||||
</form>
|
||||
</dialog>;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { validDate } from './timetable.mjs';
|
||||
|
||||
export const FRESH_MS = 90000;
|
||||
export const SNAPSHOT_MAX_MS = 24 * 60 * 60 * 1000;
|
||||
export const minskDate = now => new Intl.DateTimeFormat('sv-SE', { timeZone: 'Europe/Minsk', year: 'numeric', month: '2-digit', day: '2-digit' }).format(now);
|
||||
@@ -20,5 +22,8 @@ export function isBoardData(value, cityId, stopId) {
|
||||
return Boolean(value && value.cityId === cityId && value.stop?.id === stopId && value.mode === 'live' && Array.isArray(value.departures)
|
||||
&& Array.isArray(value.routes) && Array.isArray(value.alerts) && typeof value.serviceDate === 'string' && Number.isFinite(Date.parse(value.updatedAt))
|
||||
&& value.departures.every(item => typeof item.id === 'string' && Number.isFinite(Date.parse(item.scheduledAt)))
|
||||
&& value.alerts.every(alert => typeof alert.id === 'string' && Array.isArray(alert.routeIds) && Array.isArray(alert.routeNumbers)));
|
||||
&& value.alerts.every(alert => typeof alert.id === 'string' && Array.isArray(alert.routeIds) && Array.isArray(alert.routeNumbers)
|
||||
&& (alert.cancellations === undefined || Array.isArray(alert.cancellations) && alert.cancellations.every(target =>
|
||||
target && typeof target.routeNumber === 'string' && Number.isSafeInteger(target.stopId) && target.stopId > 0
|
||||
&& validDate(target.serviceDate) && typeof target.time === 'string' && /^([01]\d|2[0-3]):[0-5]\d$/.test(target.time)))));
|
||||
}
|
||||
|
||||
34
src/lib/cancellations.mjs
Normal file
@@ -0,0 +1,34 @@
|
||||
import { validDate } from './timetable.mjs';
|
||||
|
||||
const stopName = value => String(value || '').normalize('NFKC').trim().toLocaleLowerCase('ru').replace(/ё/g, 'е').replace(/\s+/g, ' ');
|
||||
export const normalizedRouteNumber = value => String(value || '').replace(/[\s№]/g, '').toLocaleLowerCase('ru');
|
||||
|
||||
// The existing Telegram importer publishes this exact, reviewed notice format.
|
||||
// Free-form prose and alert validity periods are not evidence of a cancelled trip.
|
||||
export function parseCancellationNotice(message, routeNumbers, stops) {
|
||||
if (typeof message !== 'string' || message.length > 5000) return [];
|
||||
const header = /^(\d{2})\.(\d{2})\.(\d{4}): не выполняются рейсы:\s*\n([\s\S]+)$/u.exec(message.trim());
|
||||
if (!header) return [];
|
||||
const serviceDate = `${header[3]}-${header[2]}-${header[1]}`;
|
||||
if (!validDate(serviceDate)) return [];
|
||||
const numbers = [...new Set(routeNumbers.map(normalizedRouteNumber))].filter(Boolean);
|
||||
let number = numbers.length === 1 ? numbers[0] : null;
|
||||
const targets = [];
|
||||
for (const line of header[4].split('\n').map(value => value.trim()).filter(Boolean)) {
|
||||
const route = /^Маршрут №([^\s:]+):$/u.exec(line);
|
||||
if (route) {
|
||||
number = normalizedRouteNumber(route[1]);
|
||||
if (!numbers.includes(number)) return [];
|
||||
continue;
|
||||
}
|
||||
const departure = /^(.+?) — ((?:[0-2]\d:[0-5]\d)(?:,\s*[0-2]\d:[0-5]\d)*)$/u.exec(line);
|
||||
if (!departure || !number) return [];
|
||||
const times = departure[2].split(/,\s*/);
|
||||
if (times.some(time => Number(time.slice(0, 2)) > 23)) return [];
|
||||
const matches = stops.filter(stop => stopName(stop.name) === stopName(departure[1]));
|
||||
// A name shared by two platforms cannot establish the affected platform.
|
||||
if (matches.length !== 1) continue;
|
||||
for (const time of times) targets.push({ routeNumber: number, stopId: Number(matches[0].id), serviceDate, time });
|
||||
}
|
||||
return targets;
|
||||
}
|
||||
49
src/lib/departures.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { Alert, Departure, Settings } from '../types';
|
||||
import { localDate } from './timetable.mjs';
|
||||
import { normalizedRouteNumber } from './cancellations.mjs';
|
||||
|
||||
export type DisplayDeparture = Departure & { status: 'scheduled' | 'notice' | 'cancelled'; alertId?: string };
|
||||
export const LONG_WAIT_MINUTES = 90;
|
||||
|
||||
export function displayDepartures(departures: Departure[], alerts: Alert[], stopId: number, now: number, reliableAlerts: boolean): DisplayDeparture[] {
|
||||
const cancellations = reliableAlerts ? alerts.filter(alert => alert.type === 'cancellation'
|
||||
&& (!alert.startAt || Date.parse(alert.startAt) <= now) && (!alert.endAt || Date.parse(alert.endAt) > now)) : [];
|
||||
const cancelledTrips = new Map<string, string>();
|
||||
const dates = new Map(departures.map(departure => [departure.id, cancellations.length ? localDate(Date.parse(departure.scheduledAt)) : '']));
|
||||
for (const alert of cancellations) {
|
||||
for (const target of alert.cancellations || []) {
|
||||
const matches = departures.filter(departure => normalizedRouteNumber(target.routeNumber) === normalizedRouteNumber(departure.routeNumber)
|
||||
&& (alert.routeIds.includes(departure.routeId) || alert.routeNumbers.some(value => normalizedRouteNumber(value) === normalizedRouteNumber(departure.routeNumber))
|
||||
|| !alert.routeIds.length && !alert.routeNumbers.length)
|
||||
&& (target.stopId === stopId && target.serviceDate === dates.get(departure.id) && target.time === departure.scheduledTime
|
||||
|| target.stopId === departure.originStopId && target.serviceDate === departure.serviceDate && target.time === departure.originTime));
|
||||
if (matches.length === 1) cancelledTrips.set(matches[0].id, alert.id);
|
||||
}
|
||||
}
|
||||
return departures.map(departure => {
|
||||
const number = normalizedRouteNumber(departure.routeNumber);
|
||||
const relevant = cancellations.filter(alert => alert.routeIds.includes(departure.routeId)
|
||||
|| alert.routeNumbers.some(value => normalizedRouteNumber(value) === number)
|
||||
|| !alert.routeIds.length && !alert.routeNumbers.length);
|
||||
const cancelled = cancelledTrips.get(departure.id);
|
||||
return { ...departure, status: cancelled ? 'cancelled' : relevant.length ? 'notice' : 'scheduled', alertId: cancelled || relevant[0]?.id };
|
||||
});
|
||||
}
|
||||
|
||||
export function firstAvailable(departures: DisplayDeparture[]) {
|
||||
return departures.find(departure => departure.status !== 'cancelled');
|
||||
}
|
||||
export function nextSameRoute(departures: DisplayDeparture[], first: Departure | undefined) {
|
||||
if (!first) return undefined;
|
||||
return departures.find(departure => departure.status !== 'cancelled' && departure.routeId === first.routeId
|
||||
&& departure.destination === first.destination && Date.parse(departure.scheduledAt) > Date.parse(first.scheduledAt));
|
||||
}
|
||||
export function longWait(settings: Settings, departure: Departure | undefined, now: number) {
|
||||
return Boolean(settings.longWaitEnabled && departure && Date.parse(departure.scheduledAt) - now >= LONG_WAIT_MINUTES * 60000);
|
||||
}
|
||||
export function readability(settings: Settings) {
|
||||
const scale = { near: 1, medium: 1.15, far: 1.3 }[settings.viewingDistance];
|
||||
const rowHeight = { near: 62, medium: 80, far: 104 }[settings.viewingDistance];
|
||||
return { scale: scale * (settings.displayProfile === 'street' ? 1.12 : 1),
|
||||
rowHeight: rowHeight + (settings.displayProfile === 'street' ? 12 : 0), calm: settings.displayProfile === 'street' };
|
||||
}
|
||||
12
src/lib/device.mjs
Normal file
@@ -0,0 +1,12 @@
|
||||
import { colorSchemes, lightColorSchemes } from './presentation.mjs';
|
||||
export const MANAGED_KEY = 'ridebus-board-managed-v1';
|
||||
export function isManagedState(value) {
|
||||
const device = value?.device, s = device?.settings, config = value?.config;
|
||||
if (value?.state !== 'paired' || !device || !s || !config || typeof device.id !== 'string' || typeof device.name !== 'string' || !Number.isInteger(device.revision) || device.revision < 1 || !Number.isInteger(device.cityId) || device.cityId < 1 || config.cityId !== device.cityId || config.defaultStopId !== s.stopId) return false;
|
||||
if (typeof config.configured !== 'boolean' || !Number.isInteger(config.refreshSeconds) || config.refreshSeconds < 5 || config.refreshSeconds > 120 || config.timeZone !== 'Europe/Minsk') return false;
|
||||
for (const [key, min, max] of [['stopId', 1, 1000000], ['mainSeconds', 5, 120], ['infoSeconds', 5, 60], ['rowCount', 2, 6], ['adSeconds', 5, 30]]) if (!Number.isInteger(s[key]) || s[key] < min || s[key] > max) return false;
|
||||
if (s.demo !== false || !['dark', 'light'].includes(s.theme) || !['tv', 'street'].includes(s.displayProfile) || !['near', 'medium', 'far'].includes(s.viewingDistance)) return false;
|
||||
if (!colorSchemes.some(color => color.id === s.colorScheme) || !lightColorSchemes.some(color => color.id === s.lightColorScheme)) return false;
|
||||
for (const key of ['holdNear', 'infoServiceEnabled', 'infoWeatherEnabled', 'infoQrEnabled', 'showNextSameRoute', 'longWaitEnabled', 'adsEnabled']) if (typeof s[key] !== 'boolean') return false;
|
||||
return typeof s.routeFilter === 'string' && /^(|[1-9]\d{0,8})$/.test(s.routeFilter) && typeof s.adTitle === 'string' && s.adTitle.length <= 80 && typeof s.adText === 'string' && s.adText.length <= 200 && typeof config.cityName === 'string' && typeof config.stopName === 'string';
|
||||
}
|
||||
34
src/lib/information.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { Departure, Scene, Settings } from '../types';
|
||||
import { localDate, shiftDate } from './timetable.mjs';
|
||||
|
||||
export const informationOptions = [
|
||||
{ id: 'service', setting: 'infoServiceEnabled', name: 'Движение', label: 'Первый и последний рейс' },
|
||||
{ id: 'weather', setting: 'infoWeatherEnabled', name: 'Погода', label: 'Погода' },
|
||||
{ id: 'qr', setting: 'infoQrEnabled', name: 'RideBus', label: 'QR-код расписания' },
|
||||
] as const;
|
||||
|
||||
export function informationBlocks(settings: Settings) {
|
||||
return informationOptions.filter(block => settings[block.setting]);
|
||||
}
|
||||
|
||||
export function boardScenes(settings: Settings, hasAlerts: boolean, urgent: boolean): Scene[] {
|
||||
return ['departures', ...(hasAlerts || informationBlocks(settings).length ? ['info'] : []),
|
||||
...(settings.adsEnabled && !urgent ? ['ad'] : [])] as Scene[];
|
||||
}
|
||||
|
||||
// Use the complete timetable, including arrivals carried over from the previous
|
||||
// service day. A short live arrivals response cannot establish the last trip.
|
||||
export function serviceWindow(departures: Departure[], now: number, routeFilter: string) {
|
||||
const today = localDate(now);
|
||||
const todayStart = Date.parse(`${today}T00:00:00+03:00`);
|
||||
const tomorrowStart = Date.parse(`${shiftDate(today, 1)}T00:00:00+03:00`);
|
||||
const tomorrowEnd = tomorrowStart + 86400000;
|
||||
let lastToday: Departure | null = null, firstTomorrow: Departure | null = null;
|
||||
for (const departure of departures) {
|
||||
if (routeFilter && String(departure.routeId) !== routeFilter) continue;
|
||||
const at = Date.parse(departure.scheduledAt);
|
||||
if (at >= todayStart && at < tomorrowStart && (!lastToday || at > Date.parse(lastToday.scheduledAt))) lastToday = departure;
|
||||
if (at >= tomorrowStart && at < tomorrowEnd && (!firstTomorrow || at < Date.parse(firstTomorrow.scheduledAt))) firstTomorrow = departure;
|
||||
}
|
||||
return { lastToday, firstTomorrow };
|
||||
}
|
||||
@@ -7,7 +7,10 @@ import type { Timetable, Weather } from '../types';
|
||||
|
||||
const SETTINGS_KEY = 'ridebus-board-settings-v1';
|
||||
export const defaultSettings: Settings = { stopId: 1, demo: false, mainSeconds: 25, infoSeconds: 8, holdNear: true,
|
||||
rowCount: 4, routeFilter: '', theme: 'dark', colorScheme: 'lime', lightColorScheme: 'blue', adsEnabled: false, adSeconds: 7, adTitle: 'Ваше место в городе', adText: 'Короткая история. Большая аудитория.' };
|
||||
rowCount: 4, routeFilter: '', theme: 'dark', colorScheme: 'lime', lightColorScheme: 'blue',
|
||||
infoServiceEnabled: true, infoWeatherEnabled: true, infoQrEnabled: true,
|
||||
showNextSameRoute: true, longWaitEnabled: true, displayProfile: 'tv', viewingDistance: 'near',
|
||||
adsEnabled: false, adSeconds: 7, adTitle: 'Ваше место в городе', adText: 'Короткая история. Большая аудитория.' };
|
||||
export function readSettings(): Settings {
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem(SETTINGS_KEY) || '{}');
|
||||
@@ -16,6 +19,9 @@ export function readSettings(): Settings {
|
||||
mainSeconds: bound(saved.mainSeconds, 25, 5, 120), infoSeconds: bound(saved.infoSeconds, 8, 5, 60), rowCount: bound(saved.rowCount, 4, 2, 6),
|
||||
holdNear: saved.holdNear !== false, routeFilter: typeof saved.routeFilter === 'string' && /^\d*$/.test(saved.routeFilter) ? saved.routeFilter : '',
|
||||
theme: saved.theme === 'light' ? 'light' : 'dark', colorScheme: colorScheme(saved.colorScheme).id, lightColorScheme: colorScheme(saved.lightColorScheme, 'light').id,
|
||||
infoServiceEnabled: saved.infoServiceEnabled !== false, infoWeatherEnabled: saved.infoWeatherEnabled !== false, infoQrEnabled: saved.infoQrEnabled !== false,
|
||||
showNextSameRoute: saved.showNextSameRoute !== false, longWaitEnabled: saved.longWaitEnabled !== false,
|
||||
displayProfile: saved.displayProfile === 'street' ? 'street' : 'tv', viewingDistance: ['medium', 'far'].includes(saved.viewingDistance) ? saved.viewingDistance : 'near',
|
||||
adsEnabled: saved.adsEnabled === true, adSeconds: bound(saved.adSeconds, 7, 5, 30),
|
||||
adTitle: typeof saved.adTitle === 'string' ? saved.adTitle.slice(0, 80) : defaultSettings.adTitle,
|
||||
adText: typeof saved.adText === 'string' ? saved.adText.slice(0, 200) : defaultSettings.adText };
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export const TIMETABLE_MAX_MS = 7 * 86400000;
|
||||
export const localDate = now => new Intl.DateTimeFormat('sv-SE', { timeZone: 'Europe/Minsk', year: 'numeric', month: '2-digit', day: '2-digit' }).format(now);
|
||||
const dayFormat = new Intl.DateTimeFormat('sv-SE', { timeZone: 'Europe/Minsk', year: 'numeric', month: '2-digit', day: '2-digit' });
|
||||
export const localDate = now => dayFormat.format(now);
|
||||
export const shiftDate = (date, days) => new Date(Date.parse(`${date}T12:00:00Z`) + days * 86400000).toISOString().slice(0, 10);
|
||||
export function validDate(value) {
|
||||
return typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value) && Number.isFinite(Date.parse(`${value}T12:00:00Z`))
|
||||
@@ -16,6 +17,8 @@ export function isTimetable(value, cityId, stopId) {
|
||||
&& (!schedule.validUntil || validDate(schedule.validUntil)) && Array.isArray(schedule.trips) && schedule.trips.every(trip =>
|
||||
Number.isFinite(trip.arrivalSeconds) && trip.arrivalSeconds >= 0 && trip.arrivalSeconds < 172800
|
||||
&& typeof trip.destination === 'string' && typeof trip.note === 'string' && Array.isArray(trip.daysOfWeek)
|
||||
&& (trip.originStopId == null || Number.isSafeInteger(trip.originStopId) && trip.originStopId > 0)
|
||||
&& (trip.originTime == null || typeof trip.originTime === 'string' && /^([01]\d|2[0-3]):[0-5]\d$/.test(trip.originTime))
|
||||
&& Array.isArray(trip.exceptDaysOfWeek) && [...trip.daysOfWeek, ...trip.exceptDaysOfWeek].every(day => Number.isInteger(day) && day >= 1 && day <= 7)))));
|
||||
}
|
||||
export function timetableAvailable(bundle, now) {
|
||||
@@ -42,6 +45,7 @@ export function timetableDepartures(bundle, date) {
|
||||
if (arrivalDate < date || arrivalDate > shiftDate(date, 1)) continue;
|
||||
result.push({ id: `${route.id}:${serviceDate}:${index}:${scheduledAt}`, routeId: route.id, routeNumber: route.number,
|
||||
routeName: route.name, destination: trip.destination, type: route.type, scheduledAt,
|
||||
serviceDate, originStopId: trip.originStopId, originTime: trip.originTime,
|
||||
scheduledTime: new Date(Date.parse(scheduledAt) + 10800000).toISOString().slice(11, 16), note: trip.note, source: 'schedule' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ h1 { font-size: clamp(30px, 3.8vw, 76px); letter-spacing: -.05em; line-height: 1
|
||||
.information-tabs { display: none; gap: 8px; }
|
||||
.information-tabs button { font-size: 14px; border: 1px solid var(--outline); background: transparent; padding: 8px 12px; border-radius: 10px; color: var(--secondary); }
|
||||
.information-tabs button[aria-current='page'] { color: var(--accent-ink); border-color: var(--accent); background: var(--accent); }
|
||||
.info-grid { flex: 1 1 0; min-height: 0; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 24px; }
|
||||
.info-grid { flex: 1 1 0; min-height: 0; display: grid; grid-template-columns: repeat(var(--info-columns, 3), minmax(0, 1fr)); gap: 24px; }
|
||||
.info-card { min-width: 0; min-height: 0; border: 1px solid var(--line); border-radius: 24px; padding: clamp(18px, 1.7vw, 32px); display: flex; flex-direction: column; overflow: auto; container-type: size; }
|
||||
.info-card > * { flex-shrink: 0; }
|
||||
.info-card h3 { font-size: clamp(23px, 2vw, 38px); line-height: 1.15; font-weight: 600; letter-spacing: -.03em; margin: 0 0 18px; }
|
||||
@@ -101,6 +101,34 @@ h1 { font-size: clamp(30px, 3.8vw, 76px); letter-spacing: -.05em; line-height: 1
|
||||
.route-card-name { flex: 1 1 0; min-width: 0; height: 100%; display: flex; flex-direction: column; justify-content: center; }
|
||||
.route-list .destination-heading { width: 100%; font-size: clamp(22px, 3cqw, 30px); line-height: 1.2; margin: 0; font-weight: 550; letter-spacing: -.025em; overflow-wrap: anywhere; }
|
||||
.route-list-pages { display: flex; justify-content: flex-end; color: var(--muted); font-size: 16px; padding-top: 10px; }
|
||||
.service-info { container-name: service-card; }
|
||||
.service-window { flex: 1 1 0; min-height: 0; display: grid; grid-template-rows: repeat(2, minmax(0, 1fr)); gap: 20px; }
|
||||
.service-boundary { min-width: 0; min-height: 0; display: flex; flex-direction: column; justify-content: center; gap: 12px; }
|
||||
.service-boundary + .service-boundary { border-top: 1px solid var(--line); padding-top: 20px; }
|
||||
.service-label { display: flex; flex-wrap: wrap; gap: 4px 10px; color: var(--secondary); font-size: clamp(17px, 3cqw, 24px); line-height: 1.25; }
|
||||
.service-label span { color: var(--muted); font-size: .8em; align-self: center; }
|
||||
.service-boundary > time { display: block; color: var(--accent); font-size: clamp(44px, min(17cqw, 19cqh), 112px); font-weight: 600; line-height: 1; }
|
||||
.service-trip { min-width: 0; display: flex; align-items: center; gap: 12px; font-size: clamp(18px, 4cqw, 26px); line-height: 1.2; }
|
||||
.service-trip .route-badge { flex-shrink: 0; width: 72px; height: 48px; border-radius: 14px; font-size: 32px; padding: 0 8px; }
|
||||
.service-trip > span:last-child { overflow-wrap: anywhere; }
|
||||
.service-info .service-empty { margin: 0; font-size: 20px; }
|
||||
.service-info .service-unavailable { margin-block: auto; text-align: center; font-size: 22px; }
|
||||
@container service-card (min-width: 600px) {
|
||||
.service-window { grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-rows: minmax(0, 1fr); gap: 32px; }
|
||||
.service-boundary + .service-boundary { border-top: 0; border-left: 1px solid var(--line); padding-top: 0; padding-left: 32px; }
|
||||
}
|
||||
@container service-card (max-height: 380px) {
|
||||
.service-window { gap: 12px; }
|
||||
.service-boundary { gap: 8px; }
|
||||
.service-boundary + .service-boundary { padding-top: 12px; }
|
||||
.service-label { font-size: 17px; }
|
||||
.service-boundary > time { font-size: clamp(40px, min(14cqw, 25cqh), 72px); }
|
||||
.service-trip { font-size: 18px; }
|
||||
.service-trip .route-badge { width: 60px; height: 40px; font-size: 28px; border-radius: 12px; }
|
||||
}
|
||||
@container service-card (min-width: 600px) and (max-height: 380px) {
|
||||
.service-boundary + .service-boundary { padding-top: 0; padding-left: 20px; }
|
||||
}
|
||||
.weather-info { container-name: weather-card; }
|
||||
.weather-body { flex: 1 1 0; min-height: 0; display: grid; grid-template-columns: minmax(0, 1fr); align-content: center; align-items: center; justify-items: center; gap: 24px; }
|
||||
.weather-now { min-width: 0; display: flex; flex-direction: column; align-items: center; }
|
||||
@@ -404,7 +432,7 @@ h1 { font-size: clamp(30px, 3.8vw, 76px); letter-spacing: -.05em; line-height: 1
|
||||
.info-screen[data-compact='true'][data-cards='true'] { position: relative; }
|
||||
.info-screen[data-compact='true'][data-cards='true'] .info-intro { position: absolute; top: 27px; right: 20px; z-index: 1; margin: 0; }
|
||||
.info-screen[data-compact='true'][data-cards='true'] .info-intro h2 { display: none; }
|
||||
.info-screen[data-compact='true'][data-cards='true'] .info-card > h3 { padding-right: 290px; min-height: 36px; text-align: left; display: flex; align-items: center; }
|
||||
.info-screen[data-compact='true'][data-cards='true'] .info-card > h3 { padding-right: var(--info-tabs-width, 290px); min-height: 36px; text-align: left; display: flex; align-items: center; }
|
||||
.info-status { font-size: 13px; padding-top: 10px; }
|
||||
.persistent-next > .eyebrow { display: none; }
|
||||
.persistent-next { padding: 10px 0; }
|
||||
@@ -415,6 +443,69 @@ h1 { font-size: clamp(30px, 3.8vw, 76px); letter-spacing: -.05em; line-height: 1
|
||||
.weather-attribution { padding-top: 8px; font-size: 10px; }
|
||||
.weather-info { padding: 16px; }
|
||||
}
|
||||
/* Viewing distance raises type sizes; pagination preserves the available height. */
|
||||
.board { --reading-scale: 1; }
|
||||
.board[data-profile='street'], .board[data-distance='medium'], .board[data-distance='far'] { --hero-height: clamp(125px, calc(18vh * var(--reading-scale)), 270px); }
|
||||
.board h1 { font-size: clamp(30px, calc(3.8vw * var(--reading-scale)), 90px); }
|
||||
.board .clock { font-size: clamp(38px, calc(4.6vw * var(--reading-scale)), 108px); }
|
||||
.board .eyebrow { font-size: clamp(12px, calc(1.05vw * var(--reading-scale)), 26px); }
|
||||
.board .source-label { font-size: clamp(13px, calc(1.15vw * var(--reading-scale)), 28px); }
|
||||
.board .hero-destination h2 { font-size: min(calc(4.7vw * var(--reading-scale)), calc(var(--hero-height) * .34)); }
|
||||
.board .row-destination h3 { font-size: min(calc(54px * var(--reading-scale)), calc(3.5vw * var(--reading-scale)), calc(var(--row-height) * .4)); }
|
||||
.board .row-time { font-size: min(calc(42px * var(--reading-scale)), calc(var(--row-height) * .36)); }
|
||||
.board .info-card h3 { font-size: clamp(23px, calc(2vw * var(--reading-scale)), 44px); }
|
||||
.departure-content[data-next='true']:not([data-long-wait='true']) { --hero-height: clamp(134px, calc(18vh * var(--reading-scale) + 24px), 260px); }
|
||||
.board[data-row-count='5'] .departure-content[data-next='true']:not([data-long-wait='true']), .board[data-row-count='6'] .departure-content[data-next='true']:not([data-long-wait='true']) { --hero-height: clamp(118px, calc(15vh * var(--reading-scale) + 24px), 224px); }
|
||||
.hero-next { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 10px; margin-top: 8px; color: var(--secondary); font-size: min(calc(22px * var(--reading-scale)), max(15px, calc(var(--hero-height) * .13))); line-height: 1.2; }
|
||||
.hero-next time { color: var(--accent); display: inline-flex; align-items: baseline; gap: 6px; font-weight: 650; }
|
||||
.departure-status { min-width: 0; display: flex; flex-direction: column; align-items: flex-end; justify-content: center; gap: 6px; }
|
||||
.departure-change { color: var(--warning); font-size: clamp(12px, calc(1.1vw * var(--reading-scale)), 20px); line-height: 1.2; }
|
||||
.cancelled-label { display: inline-flex; align-items: center; gap: 8px; color: var(--warning); font-size: min(calc(26px * var(--reading-scale)), calc(var(--row-height, 96px) * .31), 5.5vw); font-weight: 650; white-space: nowrap; }
|
||||
.cancelled-label svg { width: .9em; height: .9em; }
|
||||
.departure-row[data-status='cancelled'] .route-badge { color: var(--warning); border-color: var(--warning); }
|
||||
.departure-row[data-status='cancelled'] h3 { color: var(--secondary); }
|
||||
.empty-departures[data-cancelled='true'] > svg { display: none; }
|
||||
.empty-departures[data-cancelled='true'] h2 { color: var(--warning); font-size: clamp(22px, calc(2.8vw * var(--reading-scale)), 44px); }
|
||||
.departure-content[data-long-wait='true'] { --hero-height: clamp(142px, calc(24vh * var(--reading-scale)), 300px); }
|
||||
.long-wait-time { min-width: 0; display: flex; flex-direction: column; align-items: flex-end; justify-content: center; gap: 8px; }
|
||||
.long-wait-time > span:first-child { color: var(--secondary); font-size: clamp(17px, calc(2vw * var(--reading-scale)), 34px); }
|
||||
.long-wait-time > time { color: var(--accent); font-size: min(calc(144px * var(--reading-scale)), calc(9vw * var(--reading-scale)), calc(var(--hero-height) * .7)); line-height: 1; font-weight: 600; white-space: nowrap; }
|
||||
.board[data-motion='calm'] .numeric-old, .board[data-motion='calm'] .destination-old { display: none; }
|
||||
.board[data-motion='calm'] .numeric-current, .board[data-motion='calm'] .destination-current { transform: none !important; }
|
||||
.board[data-motion='calm'] .numeric-glyph[data-rolling] .numeric-current, .board[data-motion='calm'] .destination-current[data-changing='true'], .board[data-motion='calm'] .scene { animation: calm-in 180ms ease-out both; }
|
||||
@keyframes calm-in { from { opacity: .35; } to { opacity: 1; } }
|
||||
@media (max-width: 1000px) {
|
||||
.board .row-destination h3 { font-size: min(calc(42px * var(--reading-scale)), calc(4vw * var(--reading-scale)), calc(var(--row-height) * .36)); }
|
||||
.board .row-time-compact { font-size: min(calc(20px * var(--reading-scale)), calc(var(--row-height) * .21)); }
|
||||
}
|
||||
@media (max-height: 620px) and (min-width: 701px) {
|
||||
.board h1 { font-size: calc(32px * var(--reading-scale)); }
|
||||
.board .clock { font-size: calc(40px * var(--reading-scale)); }
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
.board h1 { font-size: clamp(25px, calc(6.5vw * var(--reading-scale)), 54px); }
|
||||
.board .clock { font-size: clamp(27px, calc(6.8vw * var(--reading-scale)), 56px); }
|
||||
.board .eyebrow { font-size: calc(10px * var(--reading-scale)); }
|
||||
.board .source-label { font-size: calc(12px * var(--reading-scale)); }
|
||||
.board .hero-destination h2 { font-size: min(calc(6vw * var(--reading-scale)), calc(var(--hero-height) * .3)); }
|
||||
.board .row-destination h3 { font-size: min(calc(42px * var(--reading-scale)), calc(5.7vw * var(--reading-scale)), calc(var(--row-height) * .36)); }
|
||||
.board .info-card h3 { font-size: min(calc(24px * var(--reading-scale)), 7vw); }
|
||||
.hero-next { font-size: 15px; gap: 4px 7px; margin-top: 6px; }
|
||||
.departure-status { gap: 4px; }
|
||||
.departure-change { font-size: 12px; }
|
||||
.hero-departure[data-long-wait='true'] { grid-template-columns: auto minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) auto; gap: 8px 12px; }
|
||||
.hero-departure[data-long-wait='true'] > .route-badge { grid-row: 1 / 3; width: min(calc(var(--hero-height) * .7), 110px); height: min(calc(var(--hero-height) - 10px), 160px); }
|
||||
.hero-departure[data-long-wait='true'] .transport-kind { display: none; }
|
||||
.long-wait-time { grid-column: 2; align-items: flex-start; gap: 4px; }
|
||||
.long-wait-time > span:first-child { font-size: 16px; }
|
||||
.long-wait-time > time { font-size: min(56px, 14vw); }
|
||||
.board[data-distance='far'] .hero-departure:not([data-long-wait='true']) { grid-template-columns: auto minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) auto; gap: 8px 12px; }
|
||||
.board[data-distance='far'] .departure-content:not([data-long-wait='true']) { --hero-height: clamp(142px, calc(19vh * var(--reading-scale)), 220px); }
|
||||
.board[data-distance='far'] .hero-departure:not([data-long-wait='true']) > .route-badge { grid-row: 1 / 3; width: 110px; height: min(calc(var(--hero-height) - 10px), 160px); }
|
||||
.board[data-distance='far'] .hero-departure:not([data-long-wait='true']) .transport-kind { display: none; }
|
||||
.board[data-distance='far'] .hero-departure > .departure-status { grid-column: 2; align-items: flex-start; }
|
||||
.board[data-distance='far'] .hero-departure .countdown-large .countdown-value { font-size: 64px; }
|
||||
}
|
||||
::view-transition { pointer-events: none; }
|
||||
::view-transition-old(root), ::view-transition-new(root) { animation-duration: 320ms; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
@@ -424,3 +515,6 @@ h1 { font-size: clamp(30px, 3.8vw, 76px); letter-spacing: -.05em; line-height: 1
|
||||
.destination-old { visibility: hidden; }
|
||||
.destination-current { transform: none !important; }
|
||||
}
|
||||
|
||||
/* Device onboarding keeps the same black/lime typography as the board. */
|
||||
.device-setup{min-height:100dvh;background:#000;color:#fff;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:clamp(24px,4vw,64px);text-align:center;gap:clamp(22px,4vh,48px);--accent:#d5ff70}.device-setup-brand{display:flex;align-items:center;gap:12px;font-size:32px;font-weight:800;letter-spacing:-.07em}.device-setup-brand>span{display:block;width:48px;color:var(--accent)}.device-setup-brand svg{width:100%;height:auto}.device-setup-card{width:min(850px,100%);border:1px solid #38402a;border-radius:32px;padding:clamp(22px,4vw,52px);background:radial-gradient(ellipse at top,#d5ff7011,transparent 65%)}.device-setup-card h1{font-size:clamp(30px,4.4vw,62px);margin:12px 0 18px;letter-spacing:-.055em}.device-setup-card p{font-size:clamp(18px,2vw,26px);color:#b3b9aa;max-width:640px;margin:0 auto;line-height:1.4}.device-pair-code{display:flex;justify-content:center;font-weight:750;font-size:clamp(64px,min(14vw,18vh),150px);letter-spacing:.08em;margin:22px 0;color:var(--accent)}.device-pair-expiry{display:flex;justify-content:center;align-items:center;gap:10px;font-size:clamp(18px,2vw,24px);color:#b3b9aa}.device-pair-expiry .numeric-text{color:var(--accent)}.device-setup button,.managed-panel button{display:inline-flex;align-items:center;justify-content:center;gap:10px;padding:14px 20px;font-size:18px;border:1px solid #555;border-radius:14px;color:#fff;background:#161616;min-height:50px}.device-setup-card button{margin-top:24px}.device-setup-card a{display:block;margin-top:20px;color:var(--accent);font-size:20px}.device-setup-status{color:#ffd479;margin-top:20px;font-size:18px}.managed-panel{color:var(--text);background:var(--background,#000);border:1px solid var(--border,#555);border-radius:26px;padding:32px;width:min(600px,calc(100% - 32px));max-height:calc(100dvh - 32px);overflow:auto}.managed-panel::backdrop{background:#000a;backdrop-filter:blur(8px)}.managed-panel>svg{color:var(--accent)}.managed-panel h2{font-size:28px;margin:16px 0}.managed-panel strong{font-size:22px;color:var(--accent)}.managed-panel p{font-size:18px;line-height:1.5;margin:16px 0;color:var(--muted,#aaa)}.managed-panel dl{display:grid;grid-template-columns:1fr 1fr;gap:12px;font-size:16px;margin:20px 0}.managed-panel>div{display:flex;gap:12px;flex-wrap:wrap}.managed-offline{color:var(--warning);font-size:14px;position:fixed;top:8px;left:50%;transform:translateX(-50%);background:var(--background,#000);padding:6px 12px;border-radius:10px;z-index:3;max-width:90vw;text-align:center}@media(max-height:650px){.device-setup{gap:16px;padding:20px}.device-setup-card{padding:24px}.device-pair-code{margin:14px 0}.device-setup-brand{font-size:26px}.device-setup-brand>span{width:36px}}
|
||||
|
||||
11
src/types.ts
@@ -1,18 +1,23 @@
|
||||
export type Departure = {
|
||||
id: string; routeId: number; routeNumber: string; routeName: string; destination: string;
|
||||
type: string; scheduledAt: string; scheduledTime: string; note: string; source: 'schedule';
|
||||
serviceDate?: string; originStopId?: number; originTime?: string;
|
||||
};
|
||||
export type Route = { id: number; number: string; name: string; type: string };
|
||||
export type Alert = { id: string; title: string; message: string; type: string; routeNumbers: string[]; routeIds: number[]; startAt: string | null; endAt: string | null };
|
||||
export type Cancellation = { routeNumber: string; stopId: number; serviceDate: string; time: string };
|
||||
export type Alert = { id: string; title: string; message: string; type: string; routeNumbers: string[]; routeIds: number[]; startAt: string | null; endAt: string | null; cancellations?: Cancellation[] };
|
||||
export type Stop = { id: number; name: string; direction: string };
|
||||
export type BoardData = { cityId: number; cityName: string; stop: Stop; departures: Departure[]; routes: Route[];
|
||||
alerts: Alert[]; alertsAvailable: boolean; partial: boolean; updatedAt: string; serverTime?: string; serviceDate: string; mode: 'live' | 'demo' };
|
||||
export type Config = { cityId: number; cityName: string; stopName: string; defaultStopId: number; configured: boolean; refreshSeconds: number; timeZone: string; passengerBaseUrl?: string };
|
||||
export type Settings = { stopId: number; demo: boolean; mainSeconds: number; infoSeconds: number; holdNear: boolean;
|
||||
rowCount: number; routeFilter: string; theme: 'dark' | 'light'; colorScheme: string; lightColorScheme: string; adsEnabled: boolean; adSeconds: number; adTitle: string; adText: string };
|
||||
rowCount: number; routeFilter: string; theme: 'dark' | 'light'; colorScheme: string; lightColorScheme: string;
|
||||
infoServiceEnabled: boolean; infoWeatherEnabled: boolean; infoQrEnabled: boolean;
|
||||
showNextSameRoute: boolean; longWaitEnabled: boolean; displayProfile: 'tv' | 'street'; viewingDistance: 'near' | 'medium' | 'far';
|
||||
adsEnabled: boolean; adSeconds: number; adTitle: string; adText: string };
|
||||
export type Scene = 'departures' | 'info' | 'ad';
|
||||
export type Timetable = { version: 1; cityId: number; cityName: string; stop: Stop; routes: (Route & { schedules: {
|
||||
dayType: string; validFrom: string | null; validUntil: string | null; trips: { arrivalSeconds: number; destination: string; note: string; daysOfWeek: number[]; exceptDaysOfWeek: number[] }[];
|
||||
dayType: string; validFrom: string | null; validUntil: string | null; trips: { arrivalSeconds: number; destination: string; note: string; daysOfWeek: number[]; exceptDaysOfWeek: number[]; originStopId?: number; originTime?: string }[];
|
||||
}[] })[]; holidays: string[]; calendarYears: number[]; updatedAt: string; expiresAt: string };
|
||||
export type Weather = { cityId: number; stopId: number; updatedAt: string; observedAt: string; temperature: number; apparentTemperature: number;
|
||||
code: number; isDay: boolean; windSpeed: number; hourly: { at: string; temperature: number; code: number; precipitationProbability: number }[] };
|
||||
|
||||
64
src/useDeviceControl.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { Config, Settings } from './types';
|
||||
import { isManagedState, MANAGED_KEY } from './lib/device.mjs';
|
||||
export type ManagedState = { state: 'paired'; enabled: true; adminUrl: string; config: Config; device: { id: string; name: string; cityId: number; revision: number; settings: Settings } };
|
||||
type Control = { state: string; enabled?: boolean; code?: string; expiresAt?: string; adminUrl?: string };
|
||||
function cached(): ManagedState | null { try { const value = JSON.parse(localStorage.getItem(MANAGED_KEY) || 'null'); return isManagedState(value) ? value : null; } catch { return null; } }
|
||||
const info = () => ({ width: innerWidth, height: innerHeight, version: 'board-0.1.0' });
|
||||
export function useDeviceControl() {
|
||||
const [managed, setManaged] = useState<ManagedState | null>(cached), [control, setControl] = useState<Control>({ state: 'loading' });
|
||||
const [failed, setFailed] = useState(false), [retry, setRetry] = useState(0);
|
||||
const applied = useRef(0), current = useRef(managed);
|
||||
current.current = managed;
|
||||
const onApplied = useCallback((revision: number) => {
|
||||
applied.current = revision;
|
||||
try { localStorage.setItem(MANAGED_KEY, JSON.stringify(current.current)); } catch { /* Keep the current screen working even if persistent storage is unavailable. */ }
|
||||
void fetch('/api/device/heartbeat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...info(), appliedRevision: revision }) }).catch(() => {});
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
let stopped = false, timer: ReturnType<typeof setTimeout>, controller: AbortController | undefined, lastHeartbeat = 0;
|
||||
let previousState = '', halted = false, running = false, firstRequest = true;
|
||||
async function call(path: string, body?: object) {
|
||||
controller = new AbortController(); const timeout = setTimeout(() => controller?.abort(), 12000);
|
||||
try {
|
||||
const response = await fetch(`/api/device/${path}`, { method: body ? 'POST' : 'GET', cache: 'no-store', signal: controller.signal, ...(body ? { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) } : {}) });
|
||||
const value = await response.json(); if (!response.ok) throw new Error(value.error || 'devices_unavailable'); return value;
|
||||
} finally { clearTimeout(timeout); }
|
||||
}
|
||||
async function load() {
|
||||
if (stopped || halted || running) return;
|
||||
running = true;
|
||||
let delay = 15000;
|
||||
try {
|
||||
let value = await call(firstRequest && retry > 0 ? 'pair' : 'status', firstRequest && retry > 0 ? info() : undefined);
|
||||
if (stopped) return;
|
||||
firstRequest = false;
|
||||
if (value.enabled === false) { halted = true; if (!stopped) { setControl({ state: 'standalone', enabled: false }); setManaged(null); setFailed(false); } return; }
|
||||
if (value.state === 'unpaired' && previousState === 'pending') throw new Error('storage_unavailable');
|
||||
if (['unpaired', 'expired'].includes(value.state)) { if (!stopped) { setManaged(null); applied.current = 0; localStorage.removeItem(MANAGED_KEY); } value = await call('pair', info()); }
|
||||
if (value.state === 'paired' && !value.config) value = await call('status');
|
||||
if (value.state === 'paired') {
|
||||
if (!isManagedState(value)) throw new Error('invalid_settings');
|
||||
if (!stopped) { setManaged(previous => previous && previous.device.id === value.device.id && previous.device.revision === value.device.revision ? previous : value); setControl(value); setFailed(false); }
|
||||
if (Date.now() - lastHeartbeat >= 30000) { await call('heartbeat', { ...info(), appliedRevision: current.current?.device.id === value.device.id ? applied.current : 0 }); lastHeartbeat = Date.now(); }
|
||||
} else if (value.state === 'pending') {
|
||||
if (!/^\d{4}$/.test(value.code) || !Number.isFinite(Date.parse(value.expiresAt))) throw new Error('devices_unavailable');
|
||||
delay = 3000; if (!stopped) { setControl(value); setManaged(null); setFailed(false); }
|
||||
} else throw new Error('devices_unavailable');
|
||||
previousState = value.state;
|
||||
} catch (error) {
|
||||
if (!stopped) {
|
||||
const code = error instanceof Error ? error.message : 'devices_unavailable';
|
||||
setFailed(true);
|
||||
if (['device_revoked', 'device_unpaired'].includes(code)) { halted = true; setFailed(false); setManaged(null); applied.current = 0; try { localStorage.removeItem(MANAGED_KEY); } catch { /* No stored copy. */ } setControl({ state: 'revoked' }); return; }
|
||||
if (code === 'storage_unavailable') halted = true;
|
||||
if (!current.current) setControl(previous => ({ ...previous, state: code === 'storage_unavailable' ? 'storage-error' : previous.code ? 'pending' : 'error' }));
|
||||
delay = 30000;
|
||||
}
|
||||
} finally { running = false; if (!stopped && !halted) timer = setTimeout(load, delay); }
|
||||
}
|
||||
void load(); const online = () => { clearTimeout(timer); void load(); }; window.addEventListener('online', online);
|
||||
return () => { stopped = true; clearTimeout(timer); controller?.abort(); window.removeEventListener('online', online); };
|
||||
}, [retry]);
|
||||
return { managed, control, failed, onApplied, retry: () => setRetry(value => value + 1) };
|
||||
}
|
||||
@@ -85,6 +85,14 @@ test('city and platform identity are checked before restoring a snapshot', () =>
|
||||
assert.equal(isBoardData(data, 1, 2), false);
|
||||
assert.equal(isBoardData({ ...data, mode: 'demo' }, 1, 1), false);
|
||||
});
|
||||
test('malformed cancellation targets in a saved snapshot are rejected instead of cancelling unrelated trips', () => {
|
||||
const target = { routeNumber: '5', stopId: 1, serviceDate: '2026-10-08', time: '12:35' };
|
||||
const alert = { id: '1', routeIds: [37], routeNumbers: ['5'], cancellations: [target] };
|
||||
const snapshot = { ...makeSnapshot(), alerts: [alert] };
|
||||
assert.equal(isBoardData(snapshot, 1, 1), true);
|
||||
for (const damaged of [null, { ...target, stopId: -1 }, { ...target, serviceDate: '2026-02-31' }, { ...target, time: '25:00' }])
|
||||
assert.equal(isBoardData({ ...snapshot, alerts: [{ ...alert, cancellations: [damaged] }] }, 1, 1), false);
|
||||
});
|
||||
test('route filtering and transport priority use absolute arrival time', () => {
|
||||
const data = makeSnapshot();
|
||||
assert.equal(upcomingDepartures(data, now, '88').length, 0);
|
||||
|
||||
77
test/departure-features.test.mjs
Normal file
@@ -0,0 +1,77 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { normalizeAlerts, normalizeDepartures } from '../server/board.mjs';
|
||||
import { parseCancellationNotice } from '../src/lib/cancellations.mjs';
|
||||
import { displayDepartures, firstAvailable, nextSameRoute, longWait, readability } from '../src/lib/departures.ts';
|
||||
import { defaultSettings } from '../src/lib/storage.ts';
|
||||
|
||||
const now = Date.parse('2026-10-09T12:00:00+03:00');
|
||||
const stops = [{ id: 1, name: 'Швейная фабрика' }, { id: 2, name: 'Мариненко' }];
|
||||
const routes = [{ id: 6, number: '6' }, { id: 1, number: '1' }];
|
||||
const departure = (id, routeId, time, extra = {}) => ({ id, routeId, routeNumber: String(routeId), destination: 'Автовокзал', scheduledAt: `2026-10-09T${time}:00+03:00`, scheduledTime: time, ...extra });
|
||||
const raw = { id: 1, city_id: 1, route_id: 6, alert_type: 'cancellation', message: '09.10.2026: не выполняются рейсы:\nШвейная фабрика — 12:04', start_time: '2026-10-09T00:00:00+03:00', end_time: '2026-10-10T00:00:00+03:00' };
|
||||
const alerts = normalizeAlerts([raw], routes, 1, now, stops);
|
||||
|
||||
test('reviewed cancellation format binds date, route, exact stop and clock; generic prose never cancels every trip', () => {
|
||||
assert.deepEqual(alerts[0].cancellations, [{ routeNumber: '6', stopId: 1, serviceDate: '2026-10-09', time: '12:04' }]);
|
||||
const result = displayDepartures([departure('a', 6, '12:04'), departure('b', 1, '12:08'), departure('c', 6, '12:12')], alerts, 1, now, true);
|
||||
assert.deepEqual(result.map(item => item.status), ['cancelled', 'scheduled', 'notice']);
|
||||
assert.equal(firstAvailable(result).id, 'b');
|
||||
const generic = normalizeAlerts([{ ...raw, message: 'Один из рейсов отменён, уточните информацию.' }], routes, 1, now, stops);
|
||||
assert.equal(displayDepartures([departure('a', 6, '12:04')], generic, 1, now, true)[0].status, 'notice');
|
||||
});
|
||||
test('multi-route notices keep each route and stop separate; ambiguous platform names and malformed notices do not match', () => {
|
||||
const message = '09.10.2026: не выполняются рейсы:\nМаршрут №6:\nШвейная фабрика — 12:04, 13:05\n\nМаршрут №1:\nМариненко — 12:10';
|
||||
const targets = parseCancellationNotice(message, ['6', '1'], stops);
|
||||
assert.equal(targets.length, 3); assert.equal(targets[2].routeNumber, '1'); assert.equal(targets[2].stopId, 2);
|
||||
assert.deepEqual(parseCancellationNotice(message, ['6'], stops), []);
|
||||
assert.deepEqual(parseCancellationNotice(raw.message, ['6'], [...stops, { id: 3, name: 'Швейная фабрика' }]), []);
|
||||
assert.deepEqual(parseCancellationNotice(raw.message.replace('12:04', '25:04'), ['6'], stops), []);
|
||||
assert.deepEqual(parseCancellationNotice(raw.message.replace('09.10.2026', '31.02.2026'), ['6'], stops), []);
|
||||
});
|
||||
test('matching an origin cancellation reaches downstream stops and retains the previous service date after midnight', () => {
|
||||
const trip = departure('a', 6, '12:14', { originStopId: 1, originTime: '12:04', serviceDate: '2026-10-09' });
|
||||
assert.equal(displayDepartures([trip], alerts, 2, now, true)[0].status, 'cancelled');
|
||||
const overnight = departure('b', 6, '00:14', { scheduledAt: '2026-10-10T00:14:00+03:00', originStopId: 1, originTime: '23:54', serviceDate: '2026-10-09' });
|
||||
const overnightAlerts = normalizeAlerts([{ ...raw, message: raw.message.replace('12:04', '23:54') }], routes, 1, now, stops);
|
||||
assert.equal(displayDepartures([overnight], overnightAlerts, 2, now, true)[0].status, 'cancelled');
|
||||
assert.equal(displayDepartures([{ ...overnight, serviceDate: '2026-10-10' }], overnightAlerts, 2, now, true)[0].status, 'notice');
|
||||
});
|
||||
test('expired, not-yet-active or stale cancellation data cannot remove a departure; ambiguous simultaneous trips remain available', () => {
|
||||
const trips = [departure('a', 6, '12:04')];
|
||||
assert.equal(displayDepartures(trips, alerts, 1, now, false)[0].status, 'scheduled');
|
||||
assert.equal(displayDepartures(trips, [{ ...alerts[0], endAt: new Date(now).toISOString() }], 1, now, true)[0].status, 'scheduled');
|
||||
assert.equal(displayDepartures(trips, [{ ...alerts[0], startAt: new Date(now + 1000).toISOString() }], 1, now, true)[0].status, 'scheduled');
|
||||
const ambiguous = displayDepartures([...trips, departure('other-direction', 66, '12:04', { routeNumber: '6', destination: 'Калинина' })], [{ ...alerts[0], routeNumbers: ['6'] }], 1, now, true);
|
||||
assert.deepEqual(ambiguous.map(item => item.status), ['notice', 'notice']);
|
||||
assert.equal(firstAvailable([{ ...trips[0], status: 'cancelled' }]), undefined);
|
||||
});
|
||||
test('next departure uses the same route and destination, excludes duplicates and cancellations, and crosses midnight', () => {
|
||||
const first = { ...departure('a', 6, '23:54'), status: 'scheduled' };
|
||||
const next = { ...departure('d', 6, '00:40', { scheduledAt: '2026-10-10T00:40:00+03:00' }), status: 'scheduled' };
|
||||
const list = [first, { ...first, id: 'same-clock' }, { ...first, id: 'cancelled', scheduledAt: '2026-10-10T00:10:00+03:00', status: 'cancelled' },
|
||||
{ ...first, id: 'opposite', scheduledAt: '2026-10-10T00:20:00+03:00', destination: 'Калинина' }, next];
|
||||
assert.equal(nextSameRoute(list, first), next);
|
||||
assert.equal(nextSameRoute([first], first), undefined);
|
||||
assert.equal(nextSameRoute(list, undefined), undefined);
|
||||
});
|
||||
test('long wait starts at 90 minutes, can be disabled, and turns off as an arrival approaches', () => {
|
||||
const trip = departure('a', 6, '13:30');
|
||||
assert.equal(longWait(defaultSettings, trip, now), true);
|
||||
assert.equal(longWait(defaultSettings, trip, now + 1), false);
|
||||
assert.equal(longWait(defaultSettings, trip, now + 60000), false);
|
||||
assert.equal(longWait({ ...defaultSettings, longWaitEnabled: false }, trip, now), false);
|
||||
assert.equal(longWait(defaultSettings, undefined, now), false);
|
||||
});
|
||||
test('viewing distance increases text and minimum row height, while street animation is calmer', () => {
|
||||
const near = readability(defaultSettings), far = readability({ ...defaultSettings, viewingDistance: 'far' });
|
||||
const street = readability({ ...defaultSettings, displayProfile: 'street', viewingDistance: 'far' });
|
||||
assert.equal(near.scale, 1); assert.equal(near.rowHeight, 62);
|
||||
assert.ok(far.scale > near.scale && far.rowHeight > near.rowHeight);
|
||||
assert.ok(street.scale > far.scale && street.rowHeight > far.rowHeight); assert.equal(street.calm, true);
|
||||
});
|
||||
test('short live response preserves origin identity and normalizes one-digit hours for exact matching', () => {
|
||||
const result = normalizeDepartures({ routes: [{ id: 6, route_number: '6', name: 'А — Б', arrivals: [{ scheduledTime: '9:04', trip: { departureTime: '8:54', startStopId: 1 } }] }] }, Date.parse('2026-10-09T09:00:00+03:00'));
|
||||
assert.equal(result.departures[0].scheduledTime, '09:04'); assert.equal(result.departures[0].originTime, '08:54');
|
||||
assert.equal(result.departures[0].originStopId, 1); assert.equal(result.departures[0].serviceDate, '2026-10-09');
|
||||
});
|
||||
49
test/device.test.mjs
Normal file
@@ -0,0 +1,49 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { isManagedState } from '../src/lib/device.mjs';
|
||||
import { createDeviceBridge } from '../server/device.mjs';
|
||||
import { createApp } from '../server/index.mjs';
|
||||
const source = await readFile(new URL('../src/lib/storage.ts', import.meta.url), 'utf8');
|
||||
// Settings used by the actual board; no external project is required for these tests.
|
||||
const body = source.match(/export const defaultSettings: Settings = ([\s\S]*?);\nexport function/)[1];
|
||||
const defaults = Function(`return (${body})`)();
|
||||
const value = { state: 'paired', enabled: true, adminUrl: '', device: { id: 'test-device', name: 'Табло', cityId: 2, revision: 1, settings: defaults }, config: { configured: true, cityId: 2, cityName: 'Новополоцк', stopName: 'Остановка', defaultStopId: 1, refreshSeconds: 20, timeZone: 'Europe/Minsk' } };
|
||||
test('managed configuration validation rejects damaged, mismatched or unsupported settings', () => {
|
||||
assert.equal(isManagedState(value), true);
|
||||
for (const patch of [{ rowCount: 8 }, { demo: true }, { theme: 'sepia' }, { stopId: 2 }, { colorScheme: 'sky' }, { routeFilter: '-1' }, { holdNear: null }]) assert.equal(isManagedState({ ...value, device: { ...value.device, settings: { ...defaults, ...patch } } }), false);
|
||||
assert.equal(isManagedState({ ...value, config: { ...value.config, refreshSeconds: 0 } }), false);
|
||||
assert.equal(isManagedState(null), false);
|
||||
});
|
||||
test('pairing bridge keeps secrets in HttpOnly cookies, binds data to the individual device and rejects cross-site writes', async t => {
|
||||
const tokenA = 'A'.repeat(43), tokenB = 'B'.repeat(43), calls = [];
|
||||
let revoked = false;
|
||||
const bridge = createDeviceBridge({ enabled: true, baseUrl: 'https://api.example/', fetchData: async (url, init) => {
|
||||
calls.push({ path: url.pathname, token: init.headers['X-Board-Token'], body: init.body });
|
||||
if (url.pathname.endsWith('/pairings')) return Response.json({ token: tokenA, state: 'pending', code: '0847', expiresAt: new Date(Date.now()+300000).toISOString() });
|
||||
if (revoked) return Response.json({ error: 'secret raw upstream' }, { status: 410 });
|
||||
if (url.pathname.endsWith('/heartbeat')) return Response.json({ ok: true });
|
||||
const cityId = init.headers['X-Board-Token'] === tokenB ? 2 : 1;
|
||||
return Response.json({ state: 'paired', device: { ...value.device, cityId, cityName: `City ${cityId}`, stopName: 'Stop', settings: { ...defaults, stopId: cityId } } });
|
||||
} });
|
||||
const factoryCalls = [], factory = ({cityId}) => ({ config: () => ({cityId}), board: async stopId => ({cityId,stop:{id:stopId}}) });
|
||||
const defaultClient = { config: () => ({ ...value.config, cityId: 1 }), board: async () => assert.fail('managed requests must use the per-city client') };
|
||||
const server = createApp(defaultClient, {}, bridge, options => { factoryCalls.push(options.cityId); return factory(options); }).listen(0,'127.0.0.1');
|
||||
await new Promise((r,j)=>{server.once('listening',r);server.once('error',j)});t.after(()=>new Promise(r=>server.close(r)));
|
||||
const base=`http://127.0.0.1:${server.address().port}`;
|
||||
const request=(path, opts={})=>fetch(base+path,opts);
|
||||
assert.equal((await request('/api/board?stopId=1')).status,401);
|
||||
const pair = await request('/api/device/pair', { method:'POST',headers:{'Content-Type':'application/json',Origin:base},body:'{}' });
|
||||
assert.equal(pair.status,200); assert.equal((await pair.json()).token,undefined);
|
||||
const cookie = pair.headers.get('set-cookie');assert.match(cookie,/HttpOnly/);assert.match(cookie,/SameSite=Strict/);
|
||||
assert.equal((await request('/api/device/pair',{method:'POST',headers:{'Content-Type':'application/json',Origin:'https://evil.example'},body:'{}'})).status,403);
|
||||
const a={Cookie:`ridebus_board_device=${tokenA}`},b={Cookie:`ridebus_board_device=${tokenB}`};
|
||||
assert.equal((await (await request('/api/device/status',{headers:a})).json()).config.cityId,1);
|
||||
assert.equal((await (await request('/api/board?stopId=1',{headers:a})).json()).cityId,1);
|
||||
assert.equal((await (await request('/api/board?stopId=2',{headers:b})).json()).cityId,2);
|
||||
assert.deepEqual(factoryCalls,[1,2]);assert.equal((await request('/api/board?stopId=2',{headers:a})).status,409);
|
||||
assert.equal((await request('/api/device/heartbeat',{method:'POST',headers:{...a,'Content-Type':'application/json'},body:'{"appliedRevision":1}'})).status,200);
|
||||
revoked=true; const result=await request('/api/device/status',{headers:a}); assert.equal(result.status,410);assert.deepEqual(await result.json(),{error:'device_revoked'});
|
||||
assert.equal((await request('/api/board?stopId=1',{headers:a})).status,410);
|
||||
assert.ok(calls.every(c=>!c.path.includes('/admin')));
|
||||
});
|
||||
1
test/fixtures/board-features.html
vendored
Normal file
@@ -0,0 +1 @@
|
||||
<!doctype html><html lang="ru"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>RideBus — проверка режимов</title></head><body><div id="root"></div><script type="module" src="./board-features.tsx"></script></body></html>
|
||||
61
test/fixtures/board-features.tsx
vendored
Normal file
@@ -0,0 +1,61 @@
|
||||
import { useState } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import '@fontsource-variable/manrope';
|
||||
import '../../src/styles.css';
|
||||
import { DeparturesScreen } from '../../src/App';
|
||||
import { defaultSettings } from '../../src/lib/storage';
|
||||
import { displayDepartures, readability } from '../../src/lib/departures';
|
||||
import { normalizeAlerts } from '../../server/board.mjs';
|
||||
import { colorScheme } from '../../src/lib/presentation.mjs';
|
||||
import type { BoardData, Departure, Settings } from '../../src/types';
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
const stop = { id: 1, name: 'Швейная фабрика', direction: '' };
|
||||
function Fixture() {
|
||||
const [scenario, setScenario] = useState('cancellation');
|
||||
const [settings, setSettings] = useState<Settings>({ ...defaultSettings, rowCount: 6 });
|
||||
const [paused, setPaused] = useState(true);
|
||||
const [approaching, setApproaching] = useState(false);
|
||||
const tomorrow = scenario === 'tomorrow';
|
||||
const distant = tomorrow || scenario === 'today';
|
||||
const baseNow = Date.parse(tomorrow ? '2026-10-09T23:20:00+03:00' : '2026-10-09T12:00:00+03:00');
|
||||
const routeNumbers = ['6', '1', '6', '2тк', '1', '17тк', '21тк', '6'];
|
||||
const minutes = distant ? [125, 140, 165, 185, 195, 210, 220, 240] : [4, 8, 12, 17, 23, 31, 38, 44];
|
||||
const departures: Departure[] = minutes.map((minutes, index) => {
|
||||
const at = tomorrow ? Date.parse('2026-10-10T05:40:00+03:00') + (minutes - 125) * 60000 : baseNow + minutes * 60000;
|
||||
const number = routeNumbers[index];
|
||||
const destination = number === '1' ? 'Богатырская' : number === '6' ? 'Автовокзал' : number === '2тк' ? 'Больница (Новка) (через Аэропорт)' : 'Тросницкая';
|
||||
return { id: String(index), routeId: index === 3 ? 20 : Number.parseInt(number), routeNumber: number,
|
||||
routeName: `Швейная фабрика — ${destination}`, destination, type: number.includes('тк') ? 'minibus' : 'bus', scheduledAt: new Date(at).toISOString(),
|
||||
scheduledTime: new Intl.DateTimeFormat('ru', { timeZone: 'Europe/Minsk', hour: '2-digit', minute: '2-digit' }).format(at), note: '', source: 'schedule' };
|
||||
});
|
||||
const now = approaching ? Date.parse(departures[0].scheduledAt) - 10 * 60000 : baseNow;
|
||||
const routes = [...new Map(departures.map(item => [item.routeId, { id: item.routeId, number: item.routeNumber, name: item.routeName, type: item.type }])).values()];
|
||||
const all = scenario === 'all-cancelled';
|
||||
const message = all ? `09.10.2026: не выполняются рейсы:\n${routes.map(route => `Маршрут №${route.number}:\nШвейная фабрика — ${departures.filter(item => item.routeId === route.id).map(item => item.scheduledTime).join(', ')}`).join('\n')}`
|
||||
: '09.10.2026: не выполняются рейсы:\nШвейная фабрика — 12:04';
|
||||
const alerts = distant ? [] : normalizeAlerts([{ id: 1, city_id: 1, route_numbers: all ? routes.map(item => item.number) : ['6'], alert_type: 'cancellation', title: 'Отмена отдельных рейсов', message, start_time: '2026-10-09T00:00:00+03:00', end_time: '2026-10-10T00:00:00+03:00' }], routes, 1, now, [stop]);
|
||||
const data: BoardData = { cityId: 1, cityName: 'Полоцк', stop, departures, routes, alerts, alertsAvailable: true, partial: false, updatedAt: new Date(now).toISOString(), serviceDate: '2026-10-09', mode: 'demo' };
|
||||
const reading = readability(settings);
|
||||
const update = <K extends keyof Settings>(key: K, value: Settings[K]) => setSettings(previous => ({ ...previous, [key]: value }));
|
||||
return <div className="board" data-theme={settings.theme} data-profile={settings.displayProfile} data-distance={settings.viewingDistance} data-row-count={settings.rowCount} data-motion={reading.calm ? 'calm' : 'full'} style={{ '--reading-scale': reading.scale, '--accent': colorScheme(settings.theme === 'light' ? settings.lightColorScheme : settings.colorScheme, settings.theme).color } as CSSProperties}>
|
||||
<header className="board-header"><h1>{stop.name}</h1><div className="header-right"><div className="clock">{new Intl.DateTimeFormat('ru', { timeZone: 'Europe/Minsk', hour: '2-digit', minute: '2-digit' }).format(now)}</div></div></header>
|
||||
<main className="scene-container"><div className="scene"><DeparturesScreen data={data} departures={displayDepartures(departures, alerts, 1, now, true)} now={now} alerts={alerts} loading={false} rowCount={settings.rowCount} fresh error="" paused={paused} settings={settings} /></div></main>
|
||||
<footer className="board-footer" style={{ position: 'relative', minHeight: 48, paddingBlock: 12 }}>
|
||||
<details><summary style={{ cursor: 'pointer', color: 'var(--muted)', fontSize: 14 }}>Тестовые данные · настройки проверки</summary>
|
||||
<div style={{ position: 'absolute', bottom: 48, left: 0, right: 0, padding: 20, border: '1px solid var(--outline)', borderRadius: 16, background: 'var(--input)', zIndex: 10, display: 'flex', flexWrap: 'wrap', gap: 16 }}>
|
||||
<label>Сценарий<select value={scenario} onChange={event => { setScenario(event.target.value); setApproaching(false); }}><option value="cancellation">Отмена</option><option value="all-cancelled">Все отменены</option><option value="today">Долгое ожидание сегодня</option><option value="tomorrow">Долгое ожидание завтра</option></select></label>
|
||||
<label>Профиль<select value={settings.displayProfile} onChange={event => update('displayProfile', event.target.value as Settings['displayProfile'])}><option value="tv">Телевизор</option><option value="street">Уличное табло</option></select></label>
|
||||
<label>Расстояние<select value={settings.viewingDistance} onChange={event => update('viewingDistance', event.target.value as Settings['viewingDistance'])}><option value="near">Близко</option><option value="medium">Средне</option><option value="far">Далеко</option></select></label>
|
||||
<label>Тема<select value={settings.theme} onChange={event => update('theme', event.target.value as Settings['theme'])}><option value="dark">Тёмная</option><option value="light">Светлая</option></select></label>
|
||||
<label>Строки<select value={settings.rowCount} onChange={event => update('rowCount', Number(event.target.value))}>{[2,4,6].map(value => <option key={value} value={value}>{value}</option>)}</select></label>
|
||||
<label>Следующий рейс<input type="checkbox" checked={settings.showNextSameRoute} onChange={event => update('showNextSameRoute', event.target.checked)} /></label>
|
||||
<label>Длительное ожидание<input type="checkbox" checked={settings.longWaitEnabled} onChange={event => update('longWaitEnabled', event.target.checked)} /></label>
|
||||
<label>До рейса 10 минут<input type="checkbox" checked={approaching} onChange={event => setApproaching(event.target.checked)} /></label>
|
||||
<label>Смена страниц<input type="checkbox" checked={!paused} onChange={event => setPaused(!event.target.checked)} /></label>
|
||||
</div>
|
||||
</details>
|
||||
</footer>
|
||||
</div>;
|
||||
}
|
||||
createRoot(document.getElementById('root')!).render(<Fixture />);
|
||||
43
test/fixtures/device-flow-server.mjs
vendored
Normal file
@@ -0,0 +1,43 @@
|
||||
// Local end-to-end fixture: real device service, router, admin UI and board bridge.
|
||||
// The database and transport data are explicit test fixtures. Never deploy this file.
|
||||
import { createRequire } from 'node:module';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { createApp } from '../../server/index.mjs';
|
||||
import { createDeviceBridge } from '../../server/device.mjs';
|
||||
import { createDemo } from '../../src/lib/demo.ts';
|
||||
const apiRequire = createRequire(new URL('../../../polotsk-transit-api/backend/package.json', import.meta.url));
|
||||
process.env.JWT_SECRET = 'local-board-fixture-only-never-production';
|
||||
apiRequire.cache[apiRequire.resolve('./src/config/database.js')] = { exports: { query: async () => ({ rows: [] }) } };
|
||||
const express = apiRequire('express'), jwt = apiRequire('jsonwebtoken');
|
||||
const { createBoardDevicesRouter } = apiRequire('./src/routes/boardDevices.js');
|
||||
const { createBoardDeviceService } = apiRequire('./src/services/boardDeviceService.js');
|
||||
const { memoryRepository } = apiRequire('./test/helpers/boardDeviceMemoryRepository.js');
|
||||
const repository = memoryRepository(), service = createBoardDeviceService(repository);
|
||||
const api = express(); api.use(express.json());
|
||||
let managementUnavailable = false;
|
||||
api.post('/__fixture/connection', (req,res) => { managementUnavailable = req.body.offline === true; res.json({ offline: managementUnavailable }); });
|
||||
api.use('/api/v1/board-devices', (_req,res,next) => managementUnavailable ? res.status(503).json({error:'fixture_offline'}) : next());
|
||||
const user = { id: 1, username: 'board-fixture', full_name: 'Тестовый оператор', role: 'admin' };
|
||||
api.post('/api/v1/auth/login', (req, res) => { if(req.body.username !== 'board-fixture' || req.body.password !== 'fixture-only') return res.status(401).json({error:'Тестовый логин: board-fixture / fixture-only'}); res.json({ user, accessToken:jwt.sign(user,process.env.JWT_SECRET,{expiresIn:'1h'}),refreshToken:'local-fixture-refresh' }); });
|
||||
api.get('/api/v1/auth/me', (_req,res)=>res.json(user));
|
||||
api.post('/api/v1/auth/logout', (_req,res)=>res.json({ok:true}));
|
||||
api.use('/api/v1/board-devices', createBoardDevicesRouter(service, { cityMiddleware: (req,res,next) => { const id=Number(req.get('X-City-Id')||1);if(![1,2].includes(id))return res.status(404).json({error:'City not found'});req.city={id};next(); } }));
|
||||
api.get('/api/v1/cities', (_req,res)=>res.json([{id:1,name:'Полоцк',slug:'polotsk',emblem:'🏛️'},{id:2,name:'Новополоцк',slug:'novopolotsk',emblem:'🏙️'}]));
|
||||
api.get('/api/v1/stops', (req,res)=>res.json(repository.stops.filter(s=>s.city_id===Number(req.get('X-City-Id')||1))));
|
||||
api.get('/api/v1/routes', (req,res)=>res.json(repository.routesData.filter(s=>s.city_id===Number(req.get('X-City-Id')||1))));
|
||||
api.get('/api/v1/alerts/active',(_req,res)=>res.json([]));api.get('/api/v1/holidays/upcoming',(_req,res)=>res.json([]));api.get('/api/v1/sync/status',(_req,res)=>res.json({status:'ready',database:{schedules_count:2}}));
|
||||
const adminDist=fileURLToPath(new URL('../../../polotsk-transit-api/frontend/dist/',import.meta.url));
|
||||
api.use(express.static(adminDist));api.get('*',async(_req,res)=>res.type('html').send((await readFile(`${adminDist}/index.html`,'utf8')).replace('</body>','<div style="position:fixed;bottom:6px;right:10px;z-index:1000;background:#fff8;padding:4px;font-size:11px">Тестовый стенд · без рабочей БД</div></body>')));
|
||||
await new Promise(r=>api.listen(5190,'127.0.0.1',r));
|
||||
const makeClient=({cityId=1,cityName='Полоцк',defaultStopId=1,stopName='Швейная фабрика'}={})=>({
|
||||
config:()=>({cityId,cityName,defaultStopId,stopName,configured:true,refreshSeconds:20,timeZone:'Europe/Minsk',passengerBaseUrl:'https://pwa.ridebus.by/'}),
|
||||
stops:async()=>repository.stops.filter(s=>s.city_id===cityId),
|
||||
board:async stopId=>({...createDemo(),cityId,cityName,stop:{id:stopId,name:repository.stops.find(s=>s.id===stopId)?.name||stopName,direction:'Тестовые данные'},mode:'live'}),
|
||||
timetable:async()=>{throw new Error('no timetable in fixture');},coordinates:async()=>null,
|
||||
});
|
||||
const bridge=createDeviceBridge({enabled:true,baseUrl:'http://127.0.0.1:5190/',adminUrl:'http://127.0.0.1:5190/devices'});
|
||||
const board=createApp(makeClient(),{weather:async()=>{throw new Error('no weather in fixture');}},bridge,makeClient);
|
||||
const boardDist=fileURLToPath(new URL('../../dist/',import.meta.url));board.use(express.static(boardDist));board.get('/{*path}',(_req,res)=>res.sendFile(`${boardDist}/index.html`));
|
||||
await new Promise(r=>board.listen(5191,'127.0.0.1',r));
|
||||
console.log('Device flow fixture: admin http://127.0.0.1:5190/devices; board http://127.0.0.1:5191/');
|
||||
42
test/information.test.mjs
Normal file
@@ -0,0 +1,42 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { boardScenes, informationBlocks, serviceWindow } from '../src/lib/information.ts';
|
||||
import { defaultSettings } from '../src/lib/storage.ts';
|
||||
|
||||
const off = { ...defaultSettings, infoServiceEnabled: false, infoWeatherEnabled: false, infoQrEnabled: false };
|
||||
const trip = (routeId, at) => ({ routeId, routeNumber: String(routeId), scheduledAt: at });
|
||||
|
||||
test('disabled information blocks are omitted without leaving gaps in the cycle', () => {
|
||||
assert.deepEqual(informationBlocks(defaultSettings).map(block => block.id), ['service', 'weather', 'qr']);
|
||||
assert.deepEqual(informationBlocks({ ...off, infoQrEnabled: true }).map(block => block.id), ['qr']);
|
||||
assert.deepEqual(informationBlocks({ ...defaultSettings, infoWeatherEnabled: false }).map(block => block.id), ['service', 'qr']);
|
||||
assert.deepEqual(informationBlocks(off), []);
|
||||
});
|
||||
test('all blocks off keeps departures, retains optional advertising, and still surfaces alerts', () => {
|
||||
assert.deepEqual(boardScenes(off, false, false), ['departures']);
|
||||
assert.deepEqual(boardScenes({ ...off, adsEnabled: true }, false, false), ['departures', 'ad']);
|
||||
assert.deepEqual(boardScenes({ ...off, adsEnabled: true }, true, true), ['departures', 'info']);
|
||||
assert.deepEqual(boardScenes(off, true, false), ['departures', 'info']);
|
||||
});
|
||||
test('service boundaries use local arrival dates, including after-midnight trips', () => {
|
||||
const now = Date.parse('2026-10-09T23:00:00+03:00');
|
||||
const last = trip(2, '2026-10-09T23:50:00+03:00');
|
||||
const first = trip(1, '2026-10-09T21:15:00Z'); // 00:15 tomorrow in Minsk
|
||||
const departures = [trip(1, '2026-10-10T06:00:00+03:00'), last, first,
|
||||
trip(1, '2026-10-09T00:10:00+03:00'), trip(1, '2026-10-08T23:59:00+03:00')];
|
||||
assert.deepEqual(serviceWindow(departures, now, ''), { lastToday: last, firstTomorrow: first });
|
||||
});
|
||||
test('route selection scopes both boundaries; empty days do not invent a trip', () => {
|
||||
const now = Date.parse('2026-10-09T12:00:00+03:00');
|
||||
const filtered = trip(1, '2026-10-09T20:00:00+03:00');
|
||||
const departures = [filtered, trip(2, '2026-10-09T23:00:00+03:00'), trip(2, '2026-10-10T05:00:00+03:00')];
|
||||
assert.deepEqual(serviceWindow(departures, now, '1'), { lastToday: filtered, firstTomorrow: null });
|
||||
assert.deepEqual(serviceWindow([], now, ''), { lastToday: null, firstTomorrow: null });
|
||||
});
|
||||
test('service boundaries roll to the new day and across the year at local midnight', () => {
|
||||
const last = trip(1, '2026-12-31T23:59:00+03:00');
|
||||
const first = trip(1, '2027-01-01T05:00:00+03:00');
|
||||
const next = trip(1, '2027-01-02T06:00:00+03:00');
|
||||
assert.deepEqual(serviceWindow([last, first, next], Date.parse('2026-12-31T23:59:00+03:00'), ''), { lastToday: last, firstTomorrow: first });
|
||||
assert.deepEqual(serviceWindow([last, first, next], Date.parse('2027-01-01T00:00:00+03:00'), ''), { lastToday: first, firstTomorrow: next });
|
||||
});
|
||||
@@ -41,3 +41,26 @@ test('old theme preferences migrate safely and each theme retains its own accent
|
||||
values.set('ridebus-board-settings-v1', JSON.stringify({ theme: 'light', lightColorScheme: 'lime' }));
|
||||
assert.equal(readSettings().lightColorScheme, 'blue');
|
||||
});
|
||||
test('information switches migrate from old settings and persist every disabled block', () => {
|
||||
const values = new Map([['ridebus-board-settings-v1', JSON.stringify({ rowCount: 6 })]]);
|
||||
globalThis.localStorage = { getItem: key => values.get(key) || null, setItem: (key, value) => values.set(key, value) };
|
||||
const old = readSettings();
|
||||
assert.equal(old.infoServiceEnabled, true); assert.equal(old.infoWeatherEnabled, true); assert.equal(old.infoQrEnabled, true);
|
||||
saveSettings({ ...old, infoServiceEnabled: false, infoWeatherEnabled: false, infoQrEnabled: false });
|
||||
const disabled = readSettings();
|
||||
assert.equal(disabled.infoServiceEnabled, false); assert.equal(disabled.infoWeatherEnabled, false); assert.equal(disabled.infoQrEnabled, false);
|
||||
assert.equal(disabled.rowCount, 6);
|
||||
saveSettings({ ...disabled, infoQrEnabled: true });
|
||||
assert.equal(readSettings().infoQrEnabled, true); assert.equal(readSettings().infoWeatherEnabled, false);
|
||||
});
|
||||
test('readability and departure display preferences migrate safely and survive reload', () => {
|
||||
const values = new Map([['ridebus-board-settings-v1', '{}']]);
|
||||
globalThis.localStorage = { getItem: key => values.get(key) || null, setItem: (key, value) => values.set(key, value) };
|
||||
const old = readSettings();
|
||||
assert.equal(old.displayProfile, 'tv'); assert.equal(old.viewingDistance, 'near'); assert.equal(old.showNextSameRoute, true); assert.equal(old.longWaitEnabled, true);
|
||||
saveSettings({ ...old, displayProfile: 'street', viewingDistance: 'far', showNextSameRoute: false, longWaitEnabled: false });
|
||||
const saved = readSettings();
|
||||
assert.equal(saved.displayProfile, 'street'); assert.equal(saved.viewingDistance, 'far'); assert.equal(saved.showNextSameRoute, false); assert.equal(saved.longWaitEnabled, false);
|
||||
values.set('ridebus-board-settings-v1', JSON.stringify({ displayProfile: 'invalid', viewingDistance: 'invalid' }));
|
||||
assert.equal(readSettings().displayProfile, 'tv'); assert.equal(readSettings().viewingDistance, 'near');
|
||||
});
|
||||
|
||||
@@ -43,6 +43,8 @@ test('previous service day retains after-midnight arrivals, with no time wrappin
|
||||
assert.equal(departures.length, 1);
|
||||
assert.equal(departures[0].scheduledTime, '00:10');
|
||||
assert.equal(departures[0].scheduledAt, '2026-10-08T21:10:00.000Z');
|
||||
assert.equal(departures[0].serviceDate, '2026-10-08');
|
||||
assert.equal(departures[0].originTime, '23:55'); assert.equal(departures[0].originStopId, 1);
|
||||
});
|
||||
test('partial and reverse trips use the start offset and correct terminal', async () => {
|
||||
const data = await bundle([schedule('weekday', [{ departureTime: '12:00', startStopId: 3, endStopId: 1, note: 'Обратно' }, { departureTime: '13:00', startStopId: 3, endStopId: 3 }])]);
|
||||
|
||||