Compare commits

...

10 Commits

Author SHA1 Message Date
6a55ee48af feat: add media query hooks and enhance responsive layout for portrait displays 2026-10-25 20:58:05 +03:00
2298b5ea13 feat: enhance info screen layout and improve fare card display with route information 2026-10-25 20:42:13 +03:00
6deac42033 feat: enhance departure status display and loading state handling 2026-10-25 20:28:56 +03:00
6811c0406a feat: enhance timetable and departure handling with new utility functions and optimizations 2026-10-25 17:26:49 +03:00
e239de45dd feat: add passenger information and settings components
- Introduced `PassengerInfo.tsx` for displaying trip details, including upcoming trips and fare information.
- Added `PassengerSettings.tsx` for managing user-defined important places and fare settings.
- Implemented validation functions for passenger settings in `passenger-settings.mjs`.
- Created utility functions for transliterating stop names in `transliteration.ts`.
- Developed weather hint functionality in `weather-hint.ts` to provide precipitation probabilities.
- Established a mock server for testing passenger-related features in `passenger-server.mjs`.
- Added comprehensive tests for passenger guide logic and transliteration functionality.
2026-10-12 20:30:49 +03:00
823ab14367 feat: add performance monitoring for journey transitions and optimize rendering
- Introduced a new markdown file documenting the optimizations made to the journey motion rendering, including changes to CSS and React components to improve performance on low-end devices.
- Implemented a test-only JavaScript file to log layout work during automatic stop changes, tracking metrics such as name resizes, font writes, and heading replacements.
- Enhanced the journey rendering by memoizing components and reducing unnecessary re-renders, aligning with web.dev recommendations for animations and layout thrashing prevention.
2026-10-11 15:13:05 +03:00
a1bb3f1c9d feat: fix tv dimension 2026-10-11 14:33:16 +03:00
5c69f6691d feat: implement night mode functionality with UI updates and journey tracking
- Added NightScreen component for displaying night mode information and alerts.
- Introduced NextStops component to show upcoming stops during a journey.
- Implemented logic for determining night mode activation based on schedule and user settings.
- Created journey utility functions to manage onward journey data and stop information.
- Updated tests to cover new night mode features and journey logic.
- Added fixture for testing night mode scenarios with specific timestamps.
- Removed QR code from night screen for a cleaner UI experience.
- Enhanced admin settings to manage night mode configurations.
2026-10-11 05:04:00 +03:00
d04b364047 feat: add remote device management and pairing functionality
- Implemented device pairing with a four-digit code and TTL for remote settings.
- Introduced automatic checks for device status and configuration validation.
- Developed UI components for device setup and management, including error handling and retry options.
- Created a device bridge for secure communication between the device and the server.
- Added tests for managed state validation and device pairing logic.
- Established a local end-to-end fixture for testing device service and API interactions.
2026-10-10 00:52:36 +03:00
e3074555a7 feat: add service window component and related functionality
- Introduced ServiceWindow component to display the last departure today and the first tomorrow.
- Implemented cancellation notice parsing to handle route cancellations effectively.
- Enhanced departure display logic to include cancellation alerts and status updates.
- Created information blocks for displaying service information, weather, and QR codes.
- Added tests for departure features, information handling, and service window functionality.
- Updated fixtures for testing various scenarios including cancellations and long wait times.
2026-10-09 23:55:47 +03:00
122 changed files with 2491 additions and 124 deletions

View File

@@ -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

1
.gitignore vendored
View File

@@ -6,3 +6,4 @@ dist/
test-results/
playwright-report/
.DS_Store
.impeccable/

View File

@@ -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

View File

@@ -18,10 +18,20 @@ npm start
## Возможности
- Главный экран целиком отдан рейсам: ближайший крупно и следующие 2–6 рейсов. Строки рассчитываются по фактической доступной высоте, нижняя панель занимает отдельное место. Если все строки не помещаются с читаемым размером текста, список сменяется страницами каждые 7 секунд с явным счётчиком. На 879×593 и 390×667 шесть рейсов показаны страницами по три; на 855×723 четыре помещаются одновременно. В узкой компоновке время отправления находится под названием и не пересекается с ожиданием.
- Главный экран целиком отдан рейсам: ближайший крупно и следующие 2–6 рейсов. Строки рассчитываются по фактической доступной высоте, нижняя панель занимает отдельное место. Если все строки не помещаются с читаемым размером текста, список сменяется страницами с явным счётчиком: каждые 7 секунд для телевизора и 10 секунд для уличного табло. В стандартном профиле на 879×593 и 390×667 шесть рейсов показаны страницами по три; на 855×723 четыре помещаются одновременно. В узкой компоновке время отправления находится под названием и не пересекается с ожиданием.
- Точная отмена выделяется непосредственно в строке рейса: время остаётся видимым, отсчёт заменяется пометкой «Отменён». Ближайшим становится следующий доступный рейс. Общие сообщения без однозначного времени и остановки показывают «Есть изменения» и сохраняют исходный текст предупреждения.
- Под ближайшим отправлением — время следующего рейса того же маршрута в том же направлении. Отменённые отправления пропускаются, завтрашняя дата отмечается один раз. Переключатель находится в настройках → «Экраны».
- При ожидании от 90 минут крупно показывается фактическое время отправления с подписью «Сегодня в» или «Завтра в». При приближении рейса возвращается обычный отсчёт. Режим можно отключить в настройках → «Экраны».
- Автоматический ночной режим включается после завершения движения по полному расписанию, если весь ночной перерыв до первого следующего рейса длиннее двух часов. На чёрном фоне — первый рейс, направление, часы без секунд и актуальные уведомления. Информация и реклама не сменяют этот экран. При оставшихся 60 минутах возвращается обычное табло; в настройках → «Экраны» можно выбрать 45 минут или выключить режим. Дневная тема и палитра восстанавливаются автоматически. По умолчанию режим автоматический, возврат за 60 минут.
- Настройки → «Оформление» → «Читаемость»: профили «Телевизор» / «Уличное табло» и расстояние «Близко» / «Средне» / «Далеко». Для улицы и дальнего просмотра текст крупнее, одновременно видимых строк меньше. Уличный профиль использует спокойное короткое появление вместо вертикальной прокрутки текста. Информационные карточки при дальнем просмотре показываются по одной.
- В шапке — название и направление конкретной площадки из API, часы и дата в `Europe/Minsk`. Поле «основная» не выдаётся за направление; если направления нет, оно не придумывается. Город, подпись «Остановка», «RideBus API» и «Все направления» убраны из шапки.
- Информационный экран: маршруты, погода, QR-код и предупреждения. Часы и ближайший рейс с цветным номером остаются видимыми. В вертикальной и короткой компоновке показывается одна карточка; при следующих посещениях информационного экрана карточки сменяются. Их также можно выбрать кнопками «Маршруты», «Погода», «RideBus».
- Блок маршрутов показывает только номер и название. На широком экране список занимает 2–3 колонки, в узкой карточке — одну. Вместимость определяется доступной высотой; остальные маршруты сменяются страницами каждые 6,5 секунды с плавным переходом названий и чисел. Пауза останавливает смену страниц. Тип транспорта, повторные времена, наклонённые дорожки, фоновые рисунки и технический статус кеша убраны.
- Информационный экран: «Дальше по ближайшему рейсу», погода, QR-код и предупреждения. Схема показывает до четырёх следующих остановок ближайшего доступного рейса и время пути от текущей остановки по расписанию. Учитывает выбранный маршрут, отмены, укороченные и обратные рейсы; на конечной не придумывает продолжение. Неизвестное время отмечено «—». Горизонтальная линия плавно сдвигается влево, выделенное название остановки увеличивается, соседние остаются компактными — по мотивам [инфосистемы «Квант»](https://www.artlebedev.ru/kvant/moscow/). Это обзор поездки после посадки, а не положение автобуса: остановки сменяются каждые 4 секунды (6 для уличного профиля), сцена автоматически удлиняется до полного обзора. В узкой карточке название и схема подстраиваются под доступное место. Часы остаются в шапке, ближайший рейс показан в нижней панели; карточка не дублирует номер, направление и время отправления. В вертикальной и короткой компоновке карточки сменяются при следующих посещениях экрана; их также можно выбрать кнопками «Маршрут», «Погода», «RideBus».
- Настройки → «Экраны» → «Транслит названий остановок»: дополнительная строка латиницей над основным названием в схеме ближайшего рейса. Например, «Пушкинская» → «Pushkinskaya». По умолчанию выключен; сохраняется после перезагрузки. Это транслитерация русских и белорусских названий, без перевода. Работает в обеих темах и профилях экрана.
- «До важных мест»: до трёх заданных остановок назначения. Для каждой — ближайший прямой рейс, отправление, время в пути и прибытие по полному расписанию. Учитываются направление, конечная конкретного рейса, отмены и переход через полночь. При неизвестных временных смещениях показывается только отправление; без актуального полного расписания с порядком остановок карточка скрыта. Реальные места по умолчанию не выбраны. Их можно задать в настройках → «Экраны» или в админке устройства. На коротком экране места сменяются страницами каждые 7 секунд (10 для улицы); сцена удлиняется, чтобы показать все страницы.
- Напоминание о последнем рейсе появляется за 30 минут до последнего отправления данного маршрута в данном направлении перед ночным перерывом. Расчёт использует ту же политику ночного перерыва, включая рейсы после полуночи, и полный действующий календарь. Это расписание, а не обещание фактического движения. Напоминание видно и на главном экране, и в нижней панели информации; его можно отключить.
- Погодная подсказка — вероятность осадков в ближайший час из почасового прогноза Open-Meteo. Нулевая вероятность сохраняется; при отсутствии ближайшего часа или устаревшем прогнозе подсказка скрыта. Переключатель — «Вероятность осадков» в настройках экранов.
- «Оплата поездки»: крупная цена и способы оплаты для ближайшего доступного маршрута, с датой подтверждения. По данным пользователя от 11 октября 2026 года для Полоцка: автобус **1,05 BYN**, маршрутка **2,10 BYN**, оплата **QR-кодом или наличными**. Основные тарифы привязаны к городу и типу транспорта; для других городов не подставляются. Можно задать исключения для конкретных маршрутов. Явно удалённые тарифы остаются удалёнными; без подходящего подтверждённого тарифа карточка скрыта. QR-код RideBus открывает расписание; платёжный код не генерируется.
- В настройках → «Экраны» → «Блоки информации» каждый блок можно включить или отключить. Отключённые и незаполненные карточки пропускаются; при четырёх и более доступных блоках они показываются по одной, чтобы сохранить читаемость. При отключении всех блоков информационный экран исключается из обычного цикла; активные предупреждения о движении всё равно показываются. Выбор сохраняется в браузере; «Сбросить цикл» включает три базовых блока, сохраняя пассажирские настройки.
- Уведомления об отменах, объездах, задержках и другие сообщения оформлены простой карточкой с иконкой, номерами маршрутов и сроком действия. Исходные заголовки, тексты и детали отмен сохраняются. Статус предупреждений отображается только при их недоступности.
- Цикл 25 секунд / 8 секунд, настраиваемый. До ближайшего рейса ≤2 минут главный экран удерживается; можно отключить в настройках. Отмены и объезды исключают рекламную сцену из цикла.
- Опциональная рекламная сцена с текстом и длительностью; по умолчанию выключена, помечена как предпросмотр. Загрузка медиа и кампании в MVP не входят.
@@ -46,6 +56,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 +78,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
```
@@ -75,3 +103,21 @@ docker compose up --build -d
На Linux-плеере откройте страницу Chromium в kiosk mode и настройте автозапуск графической сессии. Пример службы — `deploy/ridebus-kiosk.service`; пути Chromium и переменные дисплея зависят от ОС. `Restart=always` восстанавливает процесс браузера. Docker восстанавливает сервер после перезагрузки. Настройку и испытание на физическом плеере нужно выполнить отдельно: служба не обнаруживает зависший renderer, а удалённый мониторинг устройств в MVP отсутствует.
Изменения кода выкладывайте целой сборкой; Service Worker загружает новую оболочку в фоне, активируя обновление при следующем закрытии/перезапуске табло.
Ночной режим использует полный кеш с действующим сроком и календарём для вчера/сегодня/завтра. Короткий список ETA, истёкший кеш, неизвестные границы движения или отсутствие следующего рейса не включают ночной экран. Прибытия после полуночи сохраняют свой день обслуживания: последний автобус остаётся на обычном экране до окончания минутного запаса посадки. Отменённые рейсы пропускаются только по действующим достоверным уведомлениям. Фильтр маршрута ограничивает ночной режим тем же маршрутом. Длительность всего ночного перерыва определяется по расписанию, поэтому новый запуск или перезагрузка в середине перерыва сохраняет ночной экран до выбранного порога 45/60 минут. Например, после рейса Автовокзала в 00:31 и перед рейсом в 05:15 режим активен в 03:31 и возвращает обычный экран в 04:15 при пороге 60 минут или в 04:30 при пороге 45 минут.
Новые поля конфигурации устройства — `nightMode: "auto" | "off"` и `nightWakeMinutes: 45 | 60`. Они доступны и в админке «Устройства», хранятся в существующем JSON настроек. Старые локальные и удалённые настройки получают значения `auto / 60`; новая SQL-миграция не нужна. Для удалённой настройки обновите API, админку и Board. Тестовый ночной стенд: `node test/fixtures/night-server.mjs`, приложение `http://127.0.0.1:5193/`, сценарии `/__fixture` (явные тестовые расписание и часы).
Для проверки на сохранённом публичном расписании стенд принимает `NIGHT_TIMETABLE_PATH=/tmp/timetable.json` и `NIGHT_SCENARIO=avtovokzal` (03:31), `avtovokzalWake60` (04:15), `avtovokzalWake45` (04:30). Срок кеша обновляется под тестовые часы; в направлении остановки отображается «Тестовые часы». Производственные данные устройств и БД не изменяются.
Если API записывает ранний рейс 00:xx в новый календарный день, ночная логика дополнительно проверяет короткое продолжение предыдущего блока движения и первый длинный перерыв после него. Это позволяет включить режим после реального рейса 00:40 перед первым утренним в 05:44, не скрывая непрерывное ночное движение или дневные паузы. Даты рейсов и условия календаря при этом не изменяются.
Схема маршрута использует порядок и временные смещения остановок из полного кеша (`routes[].stops`) и конечную конкретного рейса (`endStopId`). Метаданные загружаются вместе с расписаниями, дополнительных запросов при смене рейса нет. Старые кеши без этих полей остаются пригодны для табло, но схема появляется после обновления полного расписания. Переключатель «Дальше по ближайшему рейсу» сохраняет прежний ключ `infoServiceEnabled`, поэтому существующие настройки локального и удалённого управления совместимы; миграция БД не требуется. Транслит управляется флагом `journeyTranslitEnabled`; в старой конфигурации он считается выключенным. Для удалённого переключения обновите табло, backend и frontend `polotsk-transit-api`: опция находится в настройках устройства, в блоке информации.
В обоих профилях схема анимирует только `transform` и `opacity`: ячейки имеют постоянную ширину, крупный и компактный варианты названия подбираются заранее и не пересоздаются при каждом переходе. В уличном профиле движение спокойнее. Схема не перерисовывается из-за тиков часов, если её данные не изменились; на конечной линия заканчивается у последнего настоящего маркера.
Для локального сравнения работы подбора шрифтов можно запустить `JOURNEY_PERF=1 node test/fixtures/night-server.mjs`. Параметр `BOARD_FIXTURE_DIST` позволяет использовать сохранённую старую сборку. Наблюдатели пишут `JOURNEY_PERF` в консоль: изменения размеров текстовых блоков, записи размера шрифта и замены заголовков за 1200 мс после смены остановки. Это диагностический стенд, а не замер FPS устройства; он не включён в рабочее табло и Docker runtime.
Пассажирские настройки устройства: `infoPlacesEnabled`, `infoFareEnabled`, `lastTripNoticeEnabled`, `weatherHintEnabled` (по умолчанию `true`); `places: [{stopId, name}]` (до 3); `fareByType: [{cityId, type, price, payment, checkedAt}]` (до 2, `type: bus | minibus`); `fareRules: [{routeId, price, payment, checkedAt}]` (до 32). Дата `checkedAt` — действительная дата `YYYY-MM-DD`, будущая дата не публикует тариф раньше срока. Сервер проверяет принадлежность остановок городу, маршрутов — площадке, основных тарифов — городу устройства. Старые конфигурации получают совместимые значения; миграция SQL не нужна. Для удалённого управления обновите backend/frontend `polotsk-transit-api` и Board вместе.
Локальный стенд для этих блоков: `node test/fixtures/passenger-server.mjs`, затем `http://127.0.0.1:5194/__fixture`. Кнопки выбирают ближайший тип транспорта и открывают табло с явными тестовыми остановками, расписанием и погодой. Стенд использует отдельный origin, не обращается к рабочей БД и не изменяет настройки основного табло.

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

BIN
artifacts/journey-light.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

BIN
artifacts/night-390x667.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

BIN
artifacts/night-879x593.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

BIN
artifacts/night-live.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

BIN
artifacts/night-no-qr.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

View 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 с Яндекс Браузером не проверялся.

View 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 перезапущен с новым серверным кодом, его автономные настройки сохранены; тестовые вкладки закрыты.

View 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`.

View File

@@ -0,0 +1,23 @@
# Читаемость схемы на 960×540 — 11 октября 2026
Проблема воспроизведена на локальном стенде с публичным расписанием Автовокзала и тестовыми часами: для названия «Педагогический колледж» оставалось **9,05 px**, текст высотой **18,08 px** пересекался с временем. Фиксированная строка минут была ниже самих цифр (26 px против 36,44 px).
Изменения:
- Высота строки времени определяется её содержимым. На короткой карточке минуты имеют 26 px, название получает оставшуюся высоту без пересечения.
- При высоте содержимого карточки до 300 px пояснения объединены в подпись «После посадки · по расписанию». При высоте до 260 px повторный верхний блок маршрута скрыт; номер, направление и отправление остаются в нижней панели ближайшего рейса.
- Стрелка схемы удалена из DOM и CSS.
- Кружки увеличены: **16 → 22 px** на короткой карточке, **18 → 24 px** на широкой. Центр линии совпадает с центром маркера.
Проверка в браузере:
- **960×540, ТВ, тёмная тема, расстояние «Близко»**: весь цикл четырёх остановок. Названия — 41,70–41,91 px, высота 47,11–47,35 px при доступных 51,49 px. Зазор до времени — 6,14–6,38 px. Кружки 22 px, стрелок нет.
- **960×540, светлая тема, расстояние «Далеко»**: после окончательного порога 260 px длинное название имеет 43,41 px, две строки высотой 98,11 px при доступных 102,12 px; зазор до времени 6,01 px. Верхний повторный маршрут скрыт, нижний остаётся видимым.
- **960×480, «Далеко»**: длинное название — 33,82 px, высота 38,21 px при доступных 42,12 px.
- **879×593**: длинное название 38,46 px / 43,45 px высоты при доступных 61,30 px; зазор 19,84 px.
- **390×667**: две строки длинного названия 24,94 px / 56,34 px высоты при доступных 60,23 px; зазор 5,89 px.
- **1920×1080, три карточки**: длинное название 34,84 px / 78,72 px высоты при доступных 238,12 px; кружок 24 px.
- Пауза и автоматическая смена остановок сохранены. Ошибок/предупреждений JavaScript нет.
- `npm run build` (TypeScript + Vite) и `git diff --check` прошли. Новые модульные тесты для CSS не добавлялись; основная проверка — воспроизведение и измерение реального DOM до/после.
Снимки: `journey-960x540-before.jpg`, `journey-960x540-fixed.jpg`, `journey-960x540-light-far.jpg`. На стенде — тестовые часы и погода, реальные сохранённые названия остановок. Снимки с отсутствующими статическими фрагментами при скрытом in-app browser перезаписаны после отображения браузера; итоговые показывают полный кадр. Проверка выполнена в браузере, без физического Xiaomi Mi Box.

View File

@@ -0,0 +1,25 @@
# Схема по мотивам «Кванта» — 11 октября 2026
Изучены [описание инфосистемы](https://www.artlebedev.ru/kvant/moscow/) и предоставленное видео `kvant-door-display-new.mp4` (44 секунды). Взяты горизонтальная линия, крупное выделенное название и согласованное движение схемы влево. Цвета, шрифт, фон и карточки сохранены из RideBus Board.
«Дальше по маршруту» теперь последовательно выделяет до четырёх следующих остановок. Новое название появляется с увеличением, выделенная ячейка расширяется, соседние остаются компактными. В конце обзор возвращается к началу через появление, без обратного движения линии. Время — от посадки по расписанию. Подпись «После посадки» обозначает обзор поездки, а не реальное положение транспорта. Данные, ограничения рейса, отмены, кеш и ключ настроек `infoServiceEnabled` сохранены.
## Проверено
- `npm run build` — TypeScript и Vite успешно.
- `npm test` — **81 из 81**, без пропусков и ошибок.
- `git diff --check` — без ошибок.
- Локальный визуальный стенд на 5193 использовал сохранённое публичное расписание Автовокзала, фильтр №4 и тестовые часы/погоду. Рабочая база и сервер не изменялись.
- **879×593**: автоматическая последовательность Поликлиника → Педагогический колледж → Школа №9 → Дом торговли → Поликлиника. Сдвиг линии: −172,54 / −345,08 / −517,62 / 0 / −172,54 px до увеличения минимальной ширины соседних остановок. Возврат в начало отмечен `data-loop=true`.
- На коротком экране после удаления повторных номеров под линией выделенные названия имеют высоту 43–58 px при доступных 62 px; размер пересчитывается для каждого названия, а не наследуется от предыдущего.
- **1079×591**: названия, время пути, схема, подпись и нижний ближайший рейс помещаются без пересечений.
- **1280×800**: сохранён полный нативный снимок широкой карточки; выделенное название и нижняя панель видны целиком.
- **1920×1080**: проверены одна широкая карточка и три карточки вместе с погодой/QR. В окончательном варианте трёх карточек «Дом торговли» — 57,16 px, две строки высотой 129,17 px при доступных 229,35 px. Частично видимые будущие остановки затухают у правого края; выделенное название видно полностью.
- **390×667, светлая тема**: «Педагогический колледж» помещается целиком на двух строках: 28,03 px, высота 63,34 px при доступных 67,40 px. Часы, ближайший рейс и нижняя панель видны.
- Уличный профиль: первая остановка сохраняется после 5,1 секунды, после 6,2 секунды выделена вторая; переход линии — 300 ms. Для ТВ — 4 секунды между остановками и 900 ms движения. Пауза останавливает обзор. Для reduced motion CSS отключает движение и появление.
- При смене ближайшего рейса с 12:15 на 21:45 обзор сброшен на первую остановку; это наблюдалось на стенде после длительного продолжения сессии.
- Ошибок и предупреждений JavaScript в проверенной вкладке нет. Настройки визуального стенда возвращены: ТВ, тёмная тема, все три блока, информация 8 секунд. Основные настройки на 5180 не менялись.
При съёмке большого скрытого in-app viewport некоторые полностраничные снимки содержали только перерисованные фрагменты текста. Нативный `getScreenshot` показывает полный кадр: итоговый широкий снимок сохранён непосредственно через него, итоговые три карточки повторно сняты и просмотрены. Дефектные промежуточные снимки удалены. Это ограничение захвата, а не доказательство поведения на физическом телевизоре. Xiaomi Mi Box в этой проверке не использовался.
Итоговые снимки: `journey-kvant-wide.jpg`, `journey-kvant-three-cards.jpg`, `journey-kvant-879x593.jpg`, `journey-kvant-1079x591.jpg`, `journey-kvant-390x667.jpg`, `journey-kvant-portrait-long.jpg`. Портрет со светлой темой показывает длинное название, широкий — основные приёмы из видео.

View File

@@ -0,0 +1,37 @@
# Прокрутка остановок — 11 октября 2026
Сообщение пользователя: тормозит на Xiaomi Mi Box 4 в Hi Browser в обоих профилях. Устройство не подключено к ADB (список устройств пуст). На физическом Mi Box FPS не измерялся; результат ниже — исходный код, сравнительные счётчики в Codex in-app browser и проверка компоновки.
## Причина и изменение
В `src/styles.css` ширина ячеек анимировалась через `flex-basis`. Каждый промежуточный размер вызывал `ResizeObserver` в `FittedDestination`, измерение текста canvas и подбор шрифта с чтением/записью геометрии. Заголовки дополнительно пересоздавались при смене роли «крупный/компактный».
В `src/NextStops.tsx` выделен мемоизированный `JourneyRail`. Ячейки всегда одинаковой ширины, каждый заголовок имеет два постоянных слоя с заранее подобранным шрифтом. Положение ячеек, увеличение названия и длина линии меняются через `transform`, видимость слоёв — через `opacity`. Коэффициент увеличения согласован со свободным расстоянием до соседней остановки. Анимации ширины, цвета текста и тени маркера удалены. Во время обычного перехода размеры текста не меняются. Тики часов не запускают рендер схемы при неизменных данных; названия, минуты, количество остановок и продолжение сравниваются перед пропуском рендера.
Это соответствует [рекомендациям web.dev по анимациям](https://web.dev/articles/animations-guide) и [предотвращению повторных синхронных перерасчётов layout](https://web.dev/articles/avoid-large-complex-layouts-and-layout-thrashing). Фактический эффект на GPU конкретного браузера требует проверки на устройстве.
## Сравнение
Одинаковый стенд: 960×540, тёмная тема, расстояние «Близко», Автовокзал, фильтр №4. Сохранённое публичное расписание, тестовые часы/погода. Старая производственная сборка сохранена до правки; новая сравнивалась с ней через те же наблюдатели. По три перехода в каждом профиле, окно 1200 мс после смены остановки. Скрипт не меняет прототипы и состояние приложения, не передаёт данные наружу. Счётчики относятся только к названиям остановок, а не ко всему layout или FPS.
| Профиль / переход | Изменения размеров до → после | Записи шрифта до → после | Замены заголовков до → после |
| --- | ---: | ---: | ---: |
| ТВ, 1 → 2 | 110 → 0 | 842 → 0 | 2 → 0 |
| ТВ, 2 → 3 | 110 → 0 | 632 → 0 | 2 → 0 |
| ТВ, 3 → 4 | 110 → 0 | 838 → 0 | 2 → 0 |
| Уличное табло, 1 → 2 | 38 → 0 | 82 → 0 | 2 → 0 |
| Уличное табло, 2 → 3 | 38 → 0 | 160 → 0 | 2 → 0 |
| Уличное табло, 3 → 4 | 38 → 0 | 241 → 0 | 2 → 0 |
## Поведение
- Сохранены интервалы 4/6 секунд и движение 900/300 мс для ТВ/уличного профиля. Пауза прекращает смену остановок.
- Переход 4 → 1 выполняет мягкое появление, без обратного движения всей схемы.
- В промежуточных кадрах ТВ: ширина ячейки постоянна **189,29 px**, высота области названия **51 px**, размер шрифта **41,91 px**. Матрица масштаба меняется 0,871 → 0,984 → 1, зазор до соседней остановки — 24,38 → 27,55 → 28,00 px. Наложения нет.
- На конечной демонстрационного маршрута без продолжения: длина линии **0 px**, её начало и центр последнего маркера совпадают (65,797 / 65,796 px). Ложного продолжения нет.
- 390×667: длинное название «Больница (Новка)» целиком, высота 72,42 px при доступных 76 px; кружок 22 px.
- 1920×1080, три карточки: тот же текст высотой 129,17 px при доступных 239 px; кружок 24 px.
- Accessibility показывает по одному названию остановки: второй визуальный слой скрыт через `aria-hidden`.
- Настройки и API не расширялись. Режим reduced motion отключает переходы и освобождает подсказки слоёв. Счётчики тестового стенда отсутствуют в production bundle и Docker runtime.
Проверки: `npm run build` (TypeScript + Vite), 10 тестов маршрута/информационных блоков, синтаксис двух файлов стенда и `git diff --check` прошли. Снимок — `journey-motion-960x540.jpg`. Для обновления серверного табло необходимо развернуть файлы и пересобрать образ.

View File

@@ -0,0 +1,30 @@
# Схема ближайшего рейса и транслит
Проверено 11 октября 2026 года.
## Изменения
- Убраны повторные номер, направление и время отправления из карточки схемы. Ближайший рейс остаётся в постоянной нижней панели.
- Карточка и её переключатель называются «Дальше по ближайшему рейсу».
- Убран заголовок «Информация» и занимаемый им отступ. Вкладки компактной компоновки и заголовок уведомлений сохранены.
- Дополнительная строка латиницей появляется над основным названием при включённом `journeyTranslitEnabled`. Основное название сохраняется, уже латинские названия не дублируются. Это читаемая транслитерация, без перевода.
- Настройка есть локально в «Экраны» и в админке устройств `polotsk-transit-api`. Старые настройки без нового поля используют `false`; миграция SQL не нужна.
## Проверки
- `ridebus-board`: TypeScript + Vite build; 84 теста пройдены, включая транслитерацию, сохранение настроек и совместимость удалённых конфигураций.
- `polotsk-transit-api`: frontend build; 8 тестов сервиса устройств и 1 тест HTTP API пройдены. Новый флаг сохраняется при удалённом обновлении и возвращается в конфигурации устройства. Повреждённые значения отклоняются.
- Браузер: 960×540, 879×593, 855×723, 390×844, 1280×800. На узкой и широкой карточке нет вертикального переполнения; латинская строка находится выше основного названия. На широкой компоновке нет пустого места от скрытого общего заголовка.
- Обе темы и оба профиля проверены на 960×540. Включение и отключение флага сохраняются после перезагрузки.
- Тестовые счётчики на переходах 1→2, 2→3, 3→4 с транслитом: в обоих профилях ноль изменений размеров блока названия и ноль записей размера шрифта за 1200 мс. Два заранее подготовленных слоя текста продолжают двигаться через transform/opacity. Это проверка работы DOM, а не измерение FPS на телевизоре.
## Ограничения
Локальный API стенда `127.0.0.1:5193` в проверочном браузере возвращал `ERR_BLOCKED_BY_CLIENT`. Сервер отдельно вернул валидный ответ и полное расписание с HTTP 200; визуальные проверки сделаны на встроенных демонстрационных данных. Xiaomi Mi Box 4 в этом запуске не проверялся.
## Примеры
- `journey-translit-960x540.png`: тёмная тема, ТВ-профиль, демонстрационные данные.
- `journey-translit-street-light-960x540.png`: светлая тема, уличный профиль, демонстрационные данные.
Для удалённого переключения нужно обновить табло, backend и frontend админки.

View File

@@ -0,0 +1,19 @@
# Дальше по маршруту — 11 октября 2026
В разделе «Информация» блок первого и последнего рейса заменён схемой следующих остановок ближайшего доступного рейса. До четырёх остановок, время в пути после посадки по расписанию, действительная конечная укороченного рейса, обратное направление. В широкой карточке схема горизонтальная, в узкой — вертикальная. Названия и числа используют существующие анимации. Неизвестное время — «—», на конечной явно показано завершение рейса.
Порядок и смещения остановок сохраняются один раз на маршрут в полном кеше; метаданные конечной — на рейс. Дополнительных запросов при смене ближайшего рейса нет. Старые кеши без схемы сохраняют работоспособность табло. Ключ `infoServiceEnabled` сохранён, обновлены подписи в локальных настройках и админке; изменения БД не требуются.
## Проверки
- Полный набор Board — **81 тест прошёл**, включая 8 новых проверок схемы: порядок и предел четырёх остановок, конечная и укороченный рейс, обратный рейс, после полуночи, неизвестные/неправильные смещения, старый/повреждённый/чужой/истёкший кеш, выбор после отмены, сохранение и восстановление офлайн.
- TypeScript/Vite Board и Vite админки собраны. Импорт сервера и `git diff --check` прошли.
- Через API прочитано полное публичное расписание Автовокзала (28): метаданные схемы присутствуют у всех 17 маршрутов.
- Браузер использовал это расписание и **явные тестовые дневные часы** (`Тестовые часы · основная`, локальный стенд). Проверялся фильтр №4, отправление 12:15: Дом торговли / 2 мин, Поликлиника / 4 мин, Педагогический колледж / 7 мин, Школа №9 / 9 мин. Длительности — от текущей остановки, без ожидания автобуса.
- Разрешения: 1920×1080 с тремя карточками, 1280×900, 1079×591, 879×593, 390×667. Карточка помещается; текст следующих остановок и время не пересекаются с нижним ближайшим рейсом. Исправлена первоначально выявленная обрезка длинного названия на коротком экране.
- Автоматический переход с первых двух остановок к третьей и четвёртой проверен на 879×593. На 390×667 со стандартной настройкой информации **8 секунд** наблюдались по порядку все четыре страницы через 4 секунды: сцена автоматически получает 16 секунд и остаётся открытой до последней страницы. На уличном профиле предусмотрено 6 секунд на страницу.
- Светлая и тёмная темы проверены. Выключение и повторное включение блока выполнены через UI; остальные карточки продолжают отображаться. Ошибок JavaScript в браузере нет.
Снимки: `journey-three-cards.jpg`, `journey-light.jpg`, `journey-1079x591.jpg`, `journey-879x593.jpg`, `journey-390x667.jpg`, `journey-portrait-last-page.jpg`. Часы и погода визуального стенда тестовые; рабочие устройства, база и удалённый сервер не изменялись.
Обновлённый основной локальный сервер на 5180 также проверен: валидный кеш «Швейной фабрики» содержит схемы всех 22 маршрутов, первый №2 в 05:44 даёт четыре следующих остановки. Основная вкладка обновлена; ночной режим и отсутствие QR сохранены. Визуальный тестовый сервер остановлен, его вкладка закрыта и viewport сброшен.

View File

@@ -0,0 +1,39 @@
# Ночной режим — 11 октября 2026
Обновлены Board, настройки API устройств и форма админки. Поля `nightMode` / `nightWakeMinutes` сохраняются локально и в существующем JSONB. Новая SQL-миграция не требуется.
Проверки: полный набор Board — **75 passed**; API — **97 passed / 15 skipped** (PostgreSQL-интеграции не настроены). После оптимизации вычисления границ движения повторно прошли 8 целевых тестов. TypeScript/Vite Board и Vite админки собраны успешно. `git diff --check` проходит в обоих проектах.
Ночной экран проверялся в настоящем приложении с явно тестовыми полным расписанием, часами и отменой (`test/fixtures/night-server.mjs`). Разрешения **1920×1080**, **1280×900**, **879×593**, **390×667**: содержимое основной сцены помещается, QR и время не пересекаются с нижней панелью. На компактном вертикальном экране уведомление занимает место QR; его полный текст медленно прокручивается внутри собственного блока. Полные снимки сохранены через screenshot(fullPage), поскольку обычный снимок in-app browser может обрезать увеличенный viewport.
Подтверждено через UI:
- После последнего вечернего рейса показывается первый завтрашний; после полуночи — «Первый рейс сегодня».
- Последний рейс в 00:15 сохраняется на обычном экране в 00:14 и в пределах запаса посадки; ночной режим возможен после его завершения.
- Уже включённый режим сохраняется при 90 минутах ожидания и выходит за 60 минут.
- Настройка 45 минут сохраняется после перезагрузки: режим остаётся при 60 минутах и возвращает светлую дневную тему на границе 45 минут.
- Отмена первого рейса в 05:40 меняет первый доступный рейс на 08:00; исходное сообщение и номер маршрута видимы в ночном блоке.
- Дневная пауза и истёкший полный кеш оставляют обычный экран.
- В админке на отдельном тестовом стенде сохранены «Автоматически / 45 минут», устройство подтвердило применение. Повторное открытие формы сохранило 45 минут; выключение отправлено следующей версией.
Снимки: `night-1920x1080.jpg`, `night-1280x900.jpg`, `night-879x593.jpg`, `night-390x667.jpg`, `night-wake-light.jpg`, `night-admin-settings.jpg`. Данные стендов не относятся к действующему расписанию или рабочей БД. Docker, физический Mi Box и производственная PostgreSQL БД в этой проверке не использовались; развёртывание на сервер не выполнялось.
На реальном полном расписании остановки выявлен отдельный рейс 00:40 (отправление 00:15), принадлежащий новому календарному дню. Логика учитывает такой короткий хвост предыдущего движения и длинный перерыв непосредственно после него; непрерывное ночное движение и более поздние дневные паузы не считаются ночным закрытием. Для этого добавлен регрессионный тест.
Итоговая сборка дополнительно проверена в основном табло на реальных данных «Швейной фабрики»: ночной режим активен, первый рейс сегодня — №2 в 05:44. Снимок `night-live.jpg`; основная вкладка оставлена открытой, тестовые серверы остановлены.
## Исправление повторного запуска — 11 октября 2026
Заголовок «Движение возобновится» заменён на «Доброй ночи». Причина ошибки Автовокзала: холодный запуск сравнивал оставшееся время с двумя часами, а уже работающий ночной режим — с выбранными 45/60 минутами. Теперь границы определяются целиком по расписанию: длительность ночного перерыва должна превышать два часа; ночной экран сохраняется до порога возврата независимо от истории вкладки. Локальная защёлка состояния удалена.
Повторно прочитано публичное полное расписание обеих платформ Автовокзала. У основной (28) последний вечерний рейс — 23:55, продолжение нового календарного дня — 00:31, первый утренний — №4 в 05:15. У конечной (21) продолжение — 00:26, первый утренний — №8 в 05:51. Для обеих расчёт в 03:31 даёт ночной режим. Эти результаты получены из текущего ответа API, без изменения дат обслуживания.
В браузере на публичном снимке расписания платформы 28 и **явно тестовых часах** подтверждены: «Доброй ночи» / №4 / 05:15 в 03:31, сохранение после перезагрузки, автоматический выход в 04:15 при 60 минутах, сохранение после перезагрузки в 04:15 при 45 минутах и выход в 04:30. Снимок 1280×900 — `night-avtovokzal-reload.jpg`. TTL снимка обновлён стендом для тестовой даты; направление помечено «Тестовые часы».
Board: **76 тестов прошли**, включая новый регрессионный сценарий 00:31 → 03:31 → 05:15. TypeScript/Vite Board и Vite админки собраны. Первый запуск двух HTTP-тестов в sandbox был заблокирован `listen EPERM`; повторный полный запуск с доступом к loopback прошёл. В админке обновлена только подсказка к ранее добавленным настройкам, логика API устройств в этом исправлении не менялась. Развёртывание на сервер не выполнялось.
## Удаление QR из ночного экрана — 11 октября 2026
QR удалён из компонента NightScreen вместе с его параметрами и отдельными стилями. Ночной блок первого рейса теперь занимает всю доступную ширину. Настройка QR в разделе «Информация» действует как прежде.
Сборка TypeScript/Vite прошла. В браузере на реальном расписании проверены 1280×900, 879×593 и 390×667: внутри ночного экрана QR и ссылки отсутствуют, карточка рейса остаётся выше нижней панели. Снимок — `night-no-qr.jpg`. Подсказка локальных настроек и описания в README обоих проектов обновлены. Блок первого и последнего рейса в «Информации» пока не заменён: пользователю предложена схема следующих остановок ближайшего автобуса.

View File

@@ -0,0 +1,28 @@
# Пассажирские блоки · 11 октября 2026
Реализованы «До важных мест», напоминание о последнем рейсе перед ночным перерывом, вероятность осадков в ближайший час и оплата ближайшего доступного маршрута. Изменены Board и backend/frontend `/Users/xtimms/Projects/polotsk-transit-api`. Места в рабочей конфигурации по умолчанию пусты. Пользователь подтвердил тарифы для Полоцка: автобус 1,05 BYN, маршрутка 2,10 BYN, QR-кодом или наличными; дата подтверждения 2026-10-11 фиксирована.
## Проверки кода
- `npm test`: 96/96, включая короткие/обратные рейсы, подтверждённые отмены, фильтр маршрута, неизвестные временные смещения, прибытие на следующий день, ночные продолжения в обоих форматах дня обслуживания, дневные перерывы, выбор тарифа и изоляцию городов.
- `npm run build` Board: TypeScript + Vite успешно.
- `node --test test/boardDevices.test.js test/boardDevicesApi.test.js` в backend: 11/11. Проверены ограничения полей, даты, чужой город/маршрут, нормализация старых настроек, сохранение пустых массивов и авторизация существующего протокола.
- `npm run build` frontend админки: успешно; прежние предупреждения о Browserslist/размере bundle остаются.
- `git diff --check` обоих проектов: без ошибок.
## Проверки браузера
Использован production build на явном локальном стенде `test/fixtures/passenger-server.mjs`, отдельный origin 5194, фиктивные остановки/расписание/погода. Настройки основного origin 5180 и рабочая БД не изменялись.
- Подтверждён действительный DOM viewport 960×540, 879×593, 855×723, 390×844 и 1440×900. Нет переполнения страницы и видимых новых карточек. На 1440×900 три карточки проверены одновременно; строки важных мест также не переполняются.
- На 960×540 название места имеет размер около 43 px, тариф — около 82 px. Места разбиваются на три страницы; наблюдался автоматический переход к следующему месту. Число страниц удлиняет сцену до полного обзора. На высоком узком экране все три коротких тестовых места помещаются сразу.
- Ближайший автобус показывает 1,05 BYN; после выбора маршрутки через видимый интерфейс стенда — 2,10 BYN. Видны способы оплаты и фиксированная дата подтверждения.
- Погода показывает 80% в ближайший час. После локального выключения подсказка исчезает, после повторного включения появляется. Светлая тема и профиль «Уличное табло» проверены на 960×540.
- Выключение важных мест сохраняется после перезагрузки и убирает вкладку. Данные мест и тарифов доступны в меню «Экраны». Кнопка «Ниже» прокрутила расширенное меню (DOM scrollTop 1099).
- На стенде `test/fixtures/device-flow-server.mjs` устройство подключено кодом 0246; сохранены места, оба тарифа и переключатели. Реальный локальный HTTP-протокол передал цену устройству. Админка показала «Настройки применены» для v1, v2 (оплата выключена) и v3 (восстановлена); выключенная вкладка исчезла с устройства. Используется репозиторий в памяти, без PostgreSQL.
- Устройство без полного расписания не показывает выбранное важное место, поскольку маршрут до него не подтверждён. Карточка оплаты продолжает работать по ближайшему доступному рейсу.
- В консоли тестового приложения ошибок/предупреждений не обнаружено.
Скриншоты: `passenger-places-960x540.png`, `passenger-weather-960x540.png`, `passenger-places-390x844.png`, `passenger-fare-minibus-960x540.jpg`, `passenger-weather-street-light-960x540.png`. Данные и время на них тестовые. Захват in-app browser через fullPage иногда пропускает неизменившиеся compositor layers; снимок не заменяет DOM-проверку заполнения/границ.
Физический Mi Box / Hi Browser, FPS устройства, PostgreSQL integration test и развёртывание на рабочем сервере в этой проверке не выполнялись. Настройки хранятся в существующем JSON, новая миграция SQL не требуется; для удалённого управления обновляются оба проекта.

View File

@@ -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"

View File

@@ -1,4 +1,6 @@
import { loadTimetable } from './timetable.mjs';
import { parseCancellationNotice } from '../src/lib/cancellations.mjs';
import { localDate, 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; }
@@ -7,13 +9,13 @@ export class ApiError extends Error {
const clean = value => typeof value === 'string' ? value.trim() : '';
const ids = value => Array.isArray(value) ? value.map(Number).filter(id => Number.isSafeInteger(id) && id > 0) : [];
const strings = value => Array.isArray(value) ? value.map(clean).filter(Boolean) : [];
export const localDate = now => new Intl.DateTimeFormat('sv-SE', { timeZone: 'Europe/Minsk', year: 'numeric', month: '2-digit', day: '2-digit' }).format(now);
export { localDate };
export function scheduledInstant(time, now) {
export function scheduledInstant(time, now, today = localDate(now)) {
const match = /^(\d{1,2}):([0-5]\d)(?::([0-5]\d))?$/.exec(clean(time));
if (!match || Number(match[1]) > 23) return null;
// ETA is a same-day timetable in Minsk. Never move an expired trip to tomorrow.
return `${localDate(now)}T${match[1].padStart(2, '0')}:${match[2]}:${match[3] || '00'}+03:00`;
return `${today}T${match[1].padStart(2, '0')}:${match[2]}:${match[3] || '00'}+03:00`;
}
function routeInfo(route) {
@@ -25,21 +27,25 @@ export function normalizeDepartures(raw, now, stopNames = new Map()) {
let partial = false;
const departures = [];
const routes = [];
const today = localDate(now), yesterday = shiftDate(today, -1);
for (const item of raw.routes) {
if (!item || !Number.isSafeInteger(Number(item.id)) || !clean(item.route_number) || !clean(item.name)) { partial = true; continue; }
const route = routeInfo(item);
routes.push(route);
if (item.error || !Array.isArray(item.arrivals)) { partial = true; continue; }
for (const arrival of item.arrivals) {
const scheduledAt = scheduledInstant(arrival?.scheduledTime, now);
const scheduledAt = scheduledInstant(arrival?.scheduledTime, now, today);
if (!scheduledAt) { partial = true; continue; }
if (Date.parse(scheduledAt) + 60000 <= now) continue;
const note = clean(arrival.trip?.note);
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, today)?.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) ? yesterday : today,
originStopId: Number(arrival.trip?.startStopId) || undefined, originTime,
source: 'schedule' });
}
}
@@ -47,7 +53,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 +72,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 +147,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
View 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;
},
};
}

View File

@@ -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;
}

View File

@@ -1,6 +1,19 @@
import { ApiError } from './board.mjs';
import { TIMETABLE_MAX_MS, localDate, shiftDate, validDate } from '../src/lib/timetable.mjs';
function normalizeStops(raw) {
if (!Array.isArray(raw)) return [];
const stops = raw.map(stop => {
const offset = stop.time_offset_minutes == null || stop.time_offset_minutes === '' ? NaN : Number(stop.time_offset_minutes);
return { id: Number(stop.stop_id), name: typeof stop.name === 'string' ? stop.name.trim() : '', sequence: Number(stop.sequence),
offsetMinutes: Number.isFinite(offset) && offset >= 0 && offset < 1440 ? offset : null };
});
// An ambiguous loop or incomplete stop identity cannot produce a reliable diagram.
if (stops.some(stop => !Number.isSafeInteger(stop.id) || stop.id < 1 || !stop.name || !Number.isSafeInteger(stop.sequence) || stop.sequence < 0)
|| new Set(stops.map(stop => stop.id)).size !== stops.length || new Set(stops.map(stop => stop.sequence)).size !== stops.length) return [];
return stops.sort((a, b) => a.sequence - b.sequence);
}
function normalizeSchedules(raw, route, rawStops, stopId) {
if (!Array.isArray(raw) || !Array.isArray(rawStops)) throw new ApiError();
const stops = new Map(rawStops.map(stop => [Number(stop.stop_id), stop]));
@@ -33,7 +46,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]}`, endStopId: Number(terminal?.stop_id),
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) }];
});
@@ -65,7 +80,7 @@ export async function loadTimetable({ upstream, cityId, cityName, stop, now }) {
while (index < routes.length) {
const current = index++, route = routes[current];
const [schedules, stops] = await Promise.all([upstream(`api/v1/routes/${route.id}/schedules`), upstream(`api/v1/routes/${route.id}/stops`)]);
result[current] = { ...route, schedules: normalizeSchedules(schedules, route, stops, Number(stop.id)) };
result[current] = { ...route, stops: normalizeStops(stops), schedules: normalizeSchedules(schedules, route, stops, Number(stop.id)) };
}
}));
return { version: 1, cityId, cityName, stop: { id: Number(stop.id), name: stop.name.trim(), direction: stop.description?.trim() || '' },

View File

@@ -1,27 +1,44 @@
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 { NextStops } from './NextStops';
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 } from './lib/information';
import { onwardJourney } from './lib/journey';
import { demoJourney } from './lib/demo';
import { boardingIndex, displayDepartures, firstAvailable, inTimeOrder, 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 { activeAlerts, isFresh, minutesUntil, shouldHoldScene, snapshotUsable } from './lib/board.mjs';
import { hasSavedSettings, readSettings, saveSettings } from './lib/storage';
import { useBoard, useClock, useTimetable, useWeather, fallbackConfig } from './hooks';
import { useBoard, useClock, useMediaQuery, useTimetable, useWeather, fallbackConfig } from './hooks';
import { SettingsPanel } from './SettingsPanel';
import { useDeviceControl } from './useDeviceControl';
import type { ManagedState } from './useDeviceControl';
import { DeviceSetup, ManagedPanel } from './DeviceSetup';
import { nightWindow } from './lib/night';
import { NightScreen } from './NightScreen';
import { PlacesCard, FareCard, LastTripNotice } from './PassengerInfo';
import { passengerGuide } from './lib/passenger-guide';
import type { PassengerGuide } from './lib/passenger-guide';
import { demoGuideTimetable } from './lib/demo';
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' });
const timeFormat = new Intl.DateTimeFormat('ru', { timeZone: 'Europe/Minsk', hour: '2-digit', minute: '2-digit' });
const noDepartures: Departure[] = [];
// A vertical stop display, not a phone: phones keep the narrow layout.
const SIGNAGE_PORTRAIT = '(orientation: portrait) and (min-width: 701px)';
const sceneNames: Record<Scene, string> = { departures: 'Ближайшие рейсы', info: 'Информация', ad: 'Реклама' };
function Countdown({ departure, now, large = false }: { departure: Departure; now: number; large?: boolean }) {
@@ -31,20 +48,40 @@ 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} />}
{/* The large countdown must not read as a live arrival estimate. One line
with the change flag keeps the hero's height unchanged. */}
{large && departure.status !== 'cancelled' ? <span className="countdown-meta"><span className="countdown-source" aria-hidden="true">по расписанию</span>
{departure.status === 'notice' && <span className="departure-change">Есть изменения</span>}</span>
: 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, lastTripId }: {
data: BoardData | null; departures: DisplayDeparture[]; now: number; alerts: Alert[]; loading: boolean; rowCount: number; fresh: boolean; error: string; paused: boolean; settings: Settings;
lastTripId?: string | null;
}) {
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 lastTrip = Boolean(first && first.id === lastTripId);
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 rows = Math.max(1, Math.min(rowCount, visible.size));
const portrait = useMediaQuery(SIGNAGE_PORTRAIT);
const capacity = Math.max(1, Math.floor(tableHeight / reading.rowHeight));
// A vertical display has room for more trips than the landscape row count;
// the operator's count stays the minimum, the viewing distance sets the row size.
const requested = portrait ? Math.max(rowCount, Math.min(8, Math.floor(tableHeight / (reading.rowHeight * 2.4)))) : rowCount;
const remaining = departures.filter(departure => departure.id !== first?.id);
const visible: { items: DisplayDeparture[]; start: number; count: number; pages: number; size: number } = departurePage(remaining, requested, capacity, page);
const rows = Math.max(1, Math.min(requested, visible.size));
useLayoutEffect(() => {
if (!table.current) return;
const observer = new ResizeObserver(entries => setTableHeight(entries[0].contentRect.height));
@@ -53,46 +90,77 @@ 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)} data-last-trip={lastTrip} 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>}
{first && lastTrip && <LastTripNotice number={first.routeNumber} />}
<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 }) {
type InfoBlock = ReturnType<typeof informationBlocks>[number];
// Below this card size the grid would shrink the journey rail and QR code past
// readability; the screen then shows one card at a time instead.
const INFO_GAP = 24, INFO_CARD_MIN_HEIGHT = 300;
function InfoScreen({ data, alerts, fresh, seconds, paused, page, onPage, onAdvance, onCompact, blocks, dwell, config, weather, now, settings, journey, guide, onPlacePages }: { data: BoardData | null; alerts: Alert[]; fresh: boolean; seconds: number; paused: boolean; page: number; onPage: (page: number) => void;
onAdvance: () => void; onCompact: (compact: boolean) => void; blocks: readonly InfoBlock[]; dwell: (id: InfoBlock['id']) => number;
config: Config; weather: ReturnType<typeof useWeather>; now: number; settings: Settings; journey: Omit<ComponentProps<typeof NextStops>, 'active'>;
guide: PassengerGuide; onPlacePages: (pages: number) => void }) {
const panel = useRef<HTMLElement>(null);
const grid = useRef<HTMLDivElement>(null);
const [compact, setCompact] = useState(false);
// Cards shown during this visit; the scene remounts on every visit.
const [shown, setShown] = useState(1);
const activeBlock = blocks[page % blocks.length]?.id;
const portrait = useMediaQuery(SIGNAGE_PORTRAIT);
// Landscape: four or five cards form two rows, up to three share one. Portrait:
// two columns from four cards (five use the journey card's double height), else a stack.
const many = blocks.length > 3;
const columns = portrait ? many ? 2 : 1 : Math.ceil(blocks.length / (many ? 2 : 1));
const rows = portrait ? many ? Math.ceil((blocks.length + blocks.length % 2) / 2) : blocks.length : many ? 2 : 1;
useEffect(() => { if (alerts.length || !settings.infoPlacesEnabled) onPlacePages(1); }, [alerts.length, settings.infoPlacesEnabled, onPlacePages]);
useEffect(() => { onCompact(compact); }, [compact, onCompact]);
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 < 650 || (rows > 1 ? (height - INFO_GAP * (rows - 1)) / rows < INFO_CARD_MIN_HEIGHT : height < 330)
|| settings.displayProfile === 'street' || settings.viewingDistance === 'far');
});
observer.observe(grid.current);
return () => observer.disconnect();
}, [alerts.length]);
}, [alerts.length, rows, settings.displayProfile, settings.viewingDistance]);
// One card at a time: show every card within this visit, each for its own time.
const activeDwell = activeBlock ? dwell(activeBlock) : 0;
useEffect(() => {
if (!compact || alerts.length || paused || shown >= blocks.length) return;
const timer = setTimeout(() => { setShown(value => value + 1); onAdvance(); }, activeDwell * 1000);
return () => clearTimeout(timer);
}, [compact, alerts.length, paused, shown, blocks.length, page, activeDwell, onAdvance]);
useEffect(() => {
const element = panel.current;
if (!element || paused || matchMedia('(prefers-reduced-motion: reduce)').matches) return;
@@ -103,29 +171,45 @@ 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="Информация для пассажиров">
<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>
return <section ref={panel} className="info-screen" data-compact={compact} data-cards={!alerts.length} data-blocks={blocks.length} data-many-blocks={blocks.length > 3}
style={{ '--info-columns': columns, '--info-rows': rows, '--info-tabs-width': `${blocks.length > 1 ? blocks.length * 100 : 0}px` } as CSSProperties} aria-label="Информация для пассажиров">
{(alerts.length > 0 || blocks.length > 1) && <div className="info-intro" data-tabs-only={!alerts.length}>
{alerts.length > 0 && <h2>Изменения движения</h2>}
{!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' && <NextStops key={journey.departure?.id || 'none'} {...journey} active={!compact || activeBlock === 'service'} />}
{block.id === 'weather' && <WeatherCard data={weather.data} now={now} loading={weather.loading} failed={weather.failed} demo={data?.mode === 'demo'} hintEnabled={settings.weatherHintEnabled} />}
{block.id === 'qr' && <PassengerQr config={config} stop={data?.stop} />}
{block.id === 'places' && <PlacesCard trips={guide.places} now={now} paused={paused} settings={settings} active={!compact || activeBlock === 'places'} onPages={onPlacePages} />}
{block.id === 'fare' && guide.fare && <FareCard fare={guide.fare} route={guide.fareRoute} />}
</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');
const [infoPage, setInfoPage] = useState(0);
const [infoCompact, setInfoCompact] = useState(false);
const [paused, setPaused] = useState(false);
const [startedAt, setStartedAt] = useState(Date.now());
const [notice, setNotice] = useState('');
@@ -135,28 +219,74 @@ export default function App() {
const timetable = useTimetable(settings, config), weather = useWeather(settings, config);
const now = useClock(offset);
const date = localDate(now);
const minuteKey = Math.floor(now / 60000);
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 alerts: Alert[] = activeAlerts(data?.alerts || [], now, settings.routeFilter, data?.routes || []);
const nightDepartures = useMemo(() => settings.nightMode === 'auto' && timetable.data ? [...new Map([...timetableDepartures(timetable.data, shiftDate(date, -1)), ...fullDepartures].map(item => [item.id, item])).values()] as Departure[] : noDepartures, [settings.nightMode, timetable.data, date, fullDepartures]);
const data: BoardData | null = useMemo(() => liveData || (fullAvailable && timetable.data ? { ...timetable.data, departures: fullDepartures, alerts: [], alertsAvailable: false, partial: false, serviceDate: date, mode: 'live' } : null),
[liveData, fullAvailable, timetable.data, fullDepartures, date]);
// The clock ticks every second, but the lists below change only when an alert
// starts or ends, new data arrives or a trip leaves. Keep their identities
// stable so the per-second render does not rescan the whole timetable.
const currentAlerts: Alert[] = activeAlerts(data?.alerts || [], now, settings.routeFilter, data?.routes || []);
const alertIds = currentAlerts.map(alert => alert.id).join('\n');
const alerts = useMemo(() => currentAlerts, [data, settings.routeFilter, alertIds]);
const fresh = settings.demo || isFresh(data, now, failed);
const held = shouldHoldScene(settings, departures[0], now);
const reliableAlerts = fresh && data?.alertsAvailable === true;
const source = fullAvailable ? fullDepartures : data?.departures || noDepartures;
// Resolve ambiguous cancellations before applying the operator's route filter.
// `alerts` is already limited to active ones, so the result does not depend on the clock.
const statusById = useMemo(() => new Map(displayDepartures(source, alerts, settings.stopId, now, reliableAlerts).map(item => [item.id, item])),
[source, alerts, settings.stopId, reliableAlerts]);
const timeline = useMemo(() => inTimeOrder(source), [source]);
const usable = fullAvailable || snapshotUsable(data, now);
const boarding = usable ? boardingIndex(timeline, now) : timeline.length;
const departures = useMemo(() => timeline.slice(boarding).filter(item => !settings.routeFilter || String(item.routeId) === settings.routeFilter).map(item => statusById.get(item.id)!),
[timeline, boarding, settings.routeFilter, statusById]);
const first = firstAvailable(departures);
const guideDemo = useMemo(() => settings.demo ? demoGuideTimetable(liveData, settings.stopId) : null, [settings.demo, settings.stopId, liveData]);
// Work once per minute or when the nearest trip/config/data changes, not on
// every clock digit: full-route searches should not tax the TV's CPU.
const guide = useMemo(() => passengerGuide(settings, settings.demo ? guideDemo : timetable.data, liveData, now, config.cityId, reliableAlerts),
[settings, guideDemo, timetable.data, liveData, minuteKey, config.cityId, reliableAlerts, first?.id, fullAvailable]);
const [placePages, setPlacePages] = useState(1);
const availablePlaces = !!guide.places.length, availableFare = !!guide.fare;
// The clock only matters here for the bundle's availability window.
const journey = useMemo(() => settings.demo ? demoJourney(first) : onwardJourney(timetable.data, first, now, config.cityId, settings.stopId),
[settings.demo, settings.stopId, config.cityId, timetable.data, first, fullAvailable, minuteKey]);
const nightAvailable = Boolean(fullAvailable && [-1, 0, 1].every(days => timetable.data?.calendarYears.includes(Number(shiftDate(date, days).slice(0, 4)))));
const nightStatuses = useMemo(() => displayDepartures(nightDepartures, alerts, settings.stopId, now, reliableAlerts), [nightDepartures, alerts, settings.stopId, reliableAlerts]);
const night = nightWindow(settings, nightStatuses, now, nightAvailable);
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 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 hasAlerts = alerts.length > 0;
const scenes = useMemo(() => night.active ? ['departures'] as Scene[] : boardScenes(settings, hasAlerts, urgent, { places: availablePlaces, fare: availableFare }), [settings, hasAlerts, urgent, night.active, availablePlaces, availableFare]);
const infoBlocks = useMemo(() => informationBlocks(settings, { places: availablePlaces, fare: availableFare }), [settings, availablePlaces, availableFare]);
// Each card needs time for its own pages: every onward stop, every page of places.
const journeyPages = Math.max(1, journey?.stops.length || 0);
const dwell = useCallback((id: string) => id === 'service' ? Math.max(settings.infoSeconds, journeyPages * (reading.calm ? 6 : 4))
: id === 'places' ? Math.max(settings.infoSeconds, placePages * (reading.calm ? 10 : 7)) : settings.infoSeconds, [settings.infoSeconds, journeyPages, placePages, reading.calm]);
const infoSeconds = hasAlerts ? Math.max(settings.infoSeconds, Math.min(60, alerts.reduce((sum, alert) => sum + alert.message.length, 0) / 18))
: infoCompact && infoBlocks.length > 1 ? infoBlocks.reduce((sum, block) => sum + dwell(block.id), 0) : Math.max(settings.infoSeconds, ...infoBlocks.map(block => dwell(block.id)));
const advanceInfo = useCallback(() => setInfoPage(value => value + 1), []);
const seconds = scene === 'departures' ? settings.mainSeconds : scene === 'info' ? infoSeconds : 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 || night.active });
sceneState.current = { scenes, scene, held, settingsOpen, paused, calm: reading.calm || night.active };
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 +298,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 +306,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 && scenes.includes('info') ? '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 +344,31 @@ 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}>
const theme = night.active ? 'dark' : settings.theme;
return <div className={`board ${!night.active && scene !== 'departures' ? 'board-secondary' : ''} ${controlsVisible ? '' : 'controls-idle'}`} data-theme={theme} data-night={night.active} data-row-count={settings.rowCount} data-profile={settings.displayProfile} data-distance={settings.viewingDistance} data-motion={reading.calm || night.active ? 'calm' : 'full'} style={{ '--reading-scale': reading.scale, '--accent': colorScheme(theme === 'light' ? settings.lightColorScheme : settings.colorScheme, 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>
<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={`Текущее время ${night.active ? `${timeParts[0]}:${timeParts[1]}` : clockFormat.format(now)}`}><NumericText value={`${timeParts[0]}:${timeParts[1]}`} />{!night.active && <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>}
{!fresh && !settings.demo && (loading
? <div className="offline-banner" data-state="loading" role="status"><Refresh /><span>{data ? 'Обновляем расписание…' : 'Подключаемся к RideBus…'}</span></div>
: <div className="offline-banner" role="status"><WifiOff /><span>{data ? `Нет свежих данных. Сохранённое расписание · обновлено в ${updateLabel}` : 'Не удалось загрузить расписание. Проверьте подключение.'}</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 === 'ad' && <section className="ad-screen"><span className="eyebrow">РЕКЛАМА · ПРЕДПРОСМОТР</span><h2>{settings.adTitle}</h2><p>{settings.adText}</p><div className="ad-signature">Время для нового.<ArrowUpRight /></div></section>}
{night.active && night.first && <NightScreen first={night.first} completedToday={night.completedToday} now={now} routeFiltered={Boolean(settings.routeFilter)} alerts={alerts} paused={paused || settingsOpen} />}
{!night.active && 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} lastTripId={guide.lastTripId} />}
{!night.active && scene === 'info' && <InfoScreen data={data} alerts={alerts} fresh={fresh} seconds={seconds} paused={paused || settingsOpen} page={infoPage} onPage={value => { setInfoPage(value); setStartedAt(Date.now()); }} onAdvance={advanceInfo} onCompact={setInfoCompact} blocks={infoBlocks} dwell={dwell}
config={config} weather={weather} now={now} settings={settings} onPlacePages={setPlacePages} guide={guide}
journey={{ departure: first, journey, loading: timetable.loading || loading, paused: paused || settingsOpen, settings }} />}
{!night.active && 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>
<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>
{!night.active && 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} />{guide.lastTripId === first.id && <LastTripNotice number={first.routeNumber} compact />}</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} ${night.active ? 'Ночной режим' : sceneNames[item]}`} title={night.active ? 'Ночной режим' : sceneNames[item]} aria-current={scene === item ? 'page' : undefined}><span className="scene-index">0{index + 1}</span><span>{night.active ? 'Ночной режим' : sceneNames[item]}</span></button>)}</nav>
<div className="footer-status">{!fresh && !settings.demo && !loading && <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
View 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>;
}

View File

@@ -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));

91
src/NextStops.tsx Normal file
View File

@@ -0,0 +1,91 @@
import { memo, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { NumericText } from './NumericText';
import { FittedDestination } from './FittedDestination';
import { RouteBadge } from './RouteBadge';
import { durationParts } from './lib/presentation.mjs';
import { readability } from './lib/departures';
import { transliterateStopName } from './lib/transliteration';
import type { OnwardJourney } from './lib/journey';
import type { Departure, Settings } from './types';
import type { CSSProperties } from 'react';
// Fixed-size cells move on the compositor. Both text sizes are fitted once,
// so moving to the next stop never resizes a cell or remounts its heading.
const JourneyRail = memo(function JourneyRail({ journey, focus, translit }: { journey: OnwardJourney; focus: number; translit: boolean }) {
const rail = useRef<HTMLDivElement>(null);
const [geometry, setGeometry] = useState({ slot: 0, focused: 0, width: 0 });
useLayoutEffect(() => {
const element = rail.current;
if (!element) return;
const measure = () => {
const cell = element.querySelector<HTMLElement>('.journey-station');
const heading = cell?.querySelector<HTMLElement>('.journey-name-focus');
const next = { slot: cell?.getBoundingClientRect().width || 0,
focused: heading ? parseFloat(getComputedStyle(heading).width) : 0, width: element.getBoundingClientRect().width };
setGeometry(previous => previous.slot === next.slot && previous.focused === next.focused && previous.width === next.width ? previous : next);
};
const observer = new ResizeObserver(measure);
observer.observe(element); measure();
return () => observer.disconnect();
}, [journey.stops.length]);
// With no continuation the line ends at the last real stop, including a
// single-stop journey. Otherwise it meets the moving dashed continuation.
const remaining = journey.stops.length - 1 - focus;
const lineEnd = !journey.more && !remaining ? 0
: (remaining + (journey.more ? 1 : 0)) * geometry.slot + geometry.focused - geometry.slot;
const scale = geometry.width > 24 && geometry.slot > 0 ? Math.max(0, lineEnd / (geometry.width - 24)) : 1;
return <div ref={rail} className="journey-rail-window" style={{ '--journey-focus': focus,
'--journey-name-scale': geometry.focused > 0 ? geometry.slot / geometry.focused : .4 } as CSSProperties}>
<span className="journey-line" aria-hidden="true" style={{ transform: `scaleX(${scale})` }} />
<ol className="journey-rail" aria-label="Следующие остановки после посадки">
{journey.stops.map((stop, index) => {
const latinName = translit ? transliterateStopName(stop.name) : stop.name;
return <li className="journey-station" key={stop.id} data-focused={index === focus} data-before={index < focus} data-after={index > focus} aria-current={index === focus ? 'step' : undefined}>
<span className="journey-duration" aria-label={stop.minutes === null ? 'Время пути неизвестно' : `В пути ${stop.minutes} мин по расписанию`}>
{stop.minutes === null ? '—' : stop.minutes === 0 ? <><span>&lt;1</span><small>мин</small></> : durationParts(stop.minutes).map(part => <span key={part.unit}><NumericText value={part.value} /><small>{part.unit}</small></span>)}
</span>
<div className="journey-station-name">
{(['context', 'focus'] as const).map(size => <div key={size} className={`journey-name-layer journey-name-${size}`} aria-hidden={size === 'focus' ? index !== focus : index === focus}>
{latinName !== stop.name && <span className="journey-transliteration" lang="ru-Latn" aria-hidden="true">{latinName}</span>}
<FittedDestination key={translit ? 'bilingual' : 'native'} text={stop.name} />
</div>)}
</div>
<span className="journey-node" aria-hidden="true" />
</li>;
})}
{journey.more > 0 && <li className="journey-continuation"><span className="journey-continuation-label">Ещё {journey.more} остановок</span><span className="journey-continuation-line" aria-hidden="true" /></li>}
</ol>
</div>;
}, (previous, next) => previous.focus === next.focus && previous.translit === next.translit && previous.journey.more === next.journey.more
&& previous.journey.stops.length === next.journey.stops.length && previous.journey.stops.every((stop, index) => {
const other = next.journey.stops[index];
return stop.id === other.id && stop.name === other.name && stop.minutes === other.minutes;
}));
export function NextStops({ departure, journey, loading, paused, settings, active }: {
departure?: Departure; journey: OnwardJourney | null; loading: boolean; paused: boolean; settings: Settings; active: boolean;
}) {
const [page, setPage] = useState(0);
const reading = readability(settings);
const count = journey?.stops.length || 0;
const pages = Math.max(1, count);
const focus = page % pages;
useEffect(() => { setPage(0); }, [departure?.id]);
useEffect(() => {
if (paused || !active || pages < 2) return;
const timer = setInterval(() => setPage(value => value + 1), reading.calm ? 6000 : 4000);
return () => clearInterval(timer);
}, [paused, active, pages, reading.calm]);
// The persistent bar names the nearest trip too, but the card must not rely on it.
return <><h3 className="card-heading"><span>Дальше по ближайшему рейсу</span>{departure && <RouteBadge number={departure.routeNumber} filled />}</h3>
{!departure ? <p className="journey-empty">{loading ? 'Получаем расписание' : 'Нет доступного рейса'}</p> : <>
{!journey ? <p className="journey-empty">{loading ? 'Загружаем остановки' : 'Схема маршрута пока недоступна'}</p> : !count ? <p className="journey-empty">Здесь конечная этого рейса</p> : <>
<div className="journey-metro" data-active={active} data-calm={reading.calm} data-loop={page > 0 && focus === 0}>
<div className="journey-tour-heading"><span>{settings.demo ? 'Демонстрация поездки' : 'После посадки'}</span><span className="journey-order">{focus + 1}-я остановка</span></div>
<div className="journey-map-space"><JourneyRail journey={journey} focus={focus} translit={settings.journeyTranslitEnabled} /></div>
</div>
<div className="journey-caption"><span className="journey-caption-full">Время пути по расписанию</span><span className="journey-caption-compact">После посадки · по расписанию</span>{pages > 1 && <span className="journey-page" aria-label={`${focus + 1} из ${count} остановок в обзоре`}>{focus + 1} / {count}</span>}</div>
</>}
</>}
</>;
}

38
src/NightScreen.tsx Normal file
View File

@@ -0,0 +1,38 @@
import { useEffect, useRef, useState } from 'react';
import { Moon } from './Icons';
import { NumericText } from './NumericText';
import { RouteBadge } from './RouteBadge';
import { AlertCard } from './AlertCard';
import { localDate } from './lib/timetable.mjs';
import type { Alert, Departure } from './types';
function NightAlerts({ alerts, paused }: { alerts: Alert[]; paused: boolean }) {
const [page, setPage] = useState(0);
const viewport = useRef<HTMLDivElement>(null);
const alert = alerts[page % alerts.length];
const duration = Math.min(90000, Math.max(25000, alert.message.length * 70 + 10000));
useEffect(() => {
if (paused) return;
const timeout = setTimeout(() => setPage(value => value + 1), duration);
return () => clearTimeout(timeout);
}, [paused, alerts.length, duration, page, alert.id]);
useEffect(() => { if (viewport.current) viewport.current.scrollTop = 0; }, [alert.id, page]);
useEffect(() => {
const element = viewport.current; if (!element) return;
if (paused) return;
let timer: ReturnType<typeof setInterval>;
const start = setTimeout(() => { timer = setInterval(() => { const overflow = element.scrollHeight - element.clientHeight; if (overflow > 0) element.scrollTop = Math.min(overflow, element.scrollTop + overflow / Math.max(1, (duration - 10000) / 100)); }, 100); }, 5000);
return () => { clearTimeout(start); clearInterval(timer); };
}, [alert.id, alert.message, paused, duration, page]);
return <div ref={viewport} className="night-alerts" aria-label="Изменения движения"><AlertCard alert={alert} />{alerts.length > 1 && <span className="night-alert-page">{page % alerts.length + 1} / {alerts.length}</span>}</div>;
}
export function NightScreen({ first, completedToday, now, alerts, paused, routeFiltered = false }: { first: Departure; completedToday: boolean; now: number; alerts: Alert[]; paused: boolean; routeFiltered?: boolean }) {
const tomorrow = localDate(Date.parse(first.scheduledAt)) > localDate(now);
return <section className="night-screen" data-alerts={alerts.length > 0} aria-label="Ночной режим">
<div className="night-intro"><span className="eyebrow"><Moon size={24} />Ночной режим</span><h2>{completedToday ? routeFiltered ? 'Рейсы маршрута на сегодня завершены' : 'Движение на сегодня завершено' : 'Доброй ночи'}</h2></div>
<div className="night-content"><div className="night-service"><span className="night-service-label">Первый рейс {tomorrow ? 'завтра' : 'сегодня'}</span><time className="night-service-time" dateTime={first.scheduledAt}><NumericText value={first.scheduledTime} /></time><div className="night-route"><RouteBadge number={first.routeNumber} filled /><h3>{first.destination}</h3></div></div>
</div>
{alerts.length > 0 && <NightAlerts alerts={alerts} paused={paused} />}
</section>;
}

54
src/PassengerInfo.tsx Normal file
View File

@@ -0,0 +1,54 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { RouteBadge } from './RouteBadge';
import { FittedDestination } from './FittedDestination';
import { NumericText } from './NumericText';
import { Clock, Moon } from './Icons';
import { localDate, shiftDate } from './lib/timetable.mjs';
import { durationParts } from './lib/presentation.mjs';
import { readability } from './lib/departures';
import type { PlaceTrip } from './lib/passenger-guide';
import type { FareTerms, Settings } from './types';
import type { CSSProperties } from 'react';
const format = new Intl.DateTimeFormat('ru', { timeZone: 'Europe/Minsk', hour: '2-digit', minute: '2-digit' });
const checked = new Intl.DateTimeFormat('ru', { timeZone: 'Europe/Minsk', day: 'numeric', month: 'long', year: 'numeric' });
function TripDay({ at, now }: { at: string; now: number }) {
const day = localDate(Date.parse(at)), today = localDate(now);
return day <= today ? null : <small>{day === shiftDate(today, 1) ? 'завтра' : day === shiftDate(today, 2) ? 'послезавтра' : day.slice(5).split('-').reverse().join('.')}</small>;
}
export function LastTripNotice({ number, compact = false }: { number: string; compact?: boolean }) {
return <div className={`last-trip-notice ${compact ? 'last-trip-compact' : ''}`} role="status"><Moon size={18} /><span>Последний №{number} перед ночным перерывом <small>· по расписанию</small></span></div>;
}
export function PlacesCard({ trips, now, paused, active, settings, onPages }: { trips: PlaceTrip[]; now: number; paused: boolean; active: boolean; settings: Settings; onPages: (pages: number) => void }) {
const body = useRef<HTMLDivElement>(null);
const [height, setHeight] = useState(0), [page, setPage] = useState(0);
const reading = readability(settings);
const size = Math.max(1, Math.min(3, Math.floor(height / (110 * reading.scale))));
const pages = Math.max(1, Math.ceil(trips.length / size));
const visible = trips.slice((page % pages) * size, (page % pages + 1) * size);
useLayoutEffect(() => {
if (!body.current) return;
const observer = new ResizeObserver(entries => setHeight(entries[0].contentRect.height)); observer.observe(body.current);
return () => observer.disconnect();
}, []);
useEffect(() => { if (active) onPages(pages); }, [active, pages, onPages]);
useEffect(() => { setPage(0); }, [settings.places]);
useEffect(() => {
if (paused || !active || pages < 2) return;
const timer = setInterval(() => setPage(value => value + 1), reading.calm ? 10000 : 7000);
return () => clearInterval(timer);
}, [paused, active, pages, reading.calm]);
return <><h3>До важных мест</h3><div className="places-body" ref={body} style={{ '--place-rows': visible.length } as CSSProperties}>
{visible.map((trip, index) => <article className="place-trip" key={`place-${index}`}>
<div className="place-copy"><FittedDestination text={trip.place.name} />
{trip.departure ? <div className="place-details"><RouteBadge number={trip.departure.routeNumber} /><time dateTime={trip.departure.scheduledAt}><NumericText value={trip.departure.scheduledTime} /><TripDay at={trip.departure.scheduledAt} now={now} /></time>
{trip.minutes !== null && <span className="place-duration" aria-label={`В пути ${trip.minutes} мин по расписанию`}><Clock size={18} />{trip.minutes === 0 ? '<1 мин' : durationParts(trip.minutes).map(part => <span key={part.unit}><NumericText value={part.value} /> {part.unit}</span>)}</span>}</div>
: <p className="place-unavailable">Ближайший прямой рейс не найден</p>}
</div>
{trip.arrivalAt && <div className="place-arrival"><span>Прибытие</span><time dateTime={trip.arrivalAt}><NumericText value={format.format(Date.parse(trip.arrivalAt))} /><TripDay at={trip.arrivalAt} now={now} /></time></div>}
</article>)}
</div><div className="passenger-caption"><span>Прямые рейсы · по расписанию</span>{pages > 1 && <span><NumericText value={`${page % pages + 1}`} /> / {pages}</span>}</div></>;
}
export function FareCard({ fare, route }: { fare: FareTerms; route: { number: string; type: string } | null }) {
return <><h3 className="card-heading"><span>Оплата поездки</span>{route && <RouteBadge number={route.number} filled />}</h3><div className="fare-body"><span className="fare-label">{route ? route.type === 'minibus' ? 'Маршрутное такси' : 'Автобус' : 'Для ближайшего маршрута'}</span><div className="fare-price"><FittedDestination text={fare.price} as="h2" /></div><div className="fare-payment"><FittedDestination text={fare.payment} /></div></div><div className="passenger-caption">Данные на {checked.format(Date.parse(`${fare.checkedAt}T12:00:00+03:00`))}</div></>;
}

View File

@@ -1,9 +1,10 @@
import { useMemo } from 'react';
import { memo, useMemo } from 'react';
import { ArrowUpRight } from './Icons';
import type { Config, Stop } from './types';
import { createPassengerQr, passengerLink } from './lib/passenger.mjs';
export function PassengerQr({ config, stop }: { config: Config; stop: Stop | undefined }) {
// Hundreds of modules: skip the clock-driven re-render of the info screen.
export const PassengerQr = memo(function PassengerQr({ config, stop }: { config: Config; stop: Stop | undefined }) {
const url = passengerLink(config.passengerBaseUrl, config.cityId, stop?.id || config.defaultStopId);
const qr = useMemo(() => createPassengerQr(url), [url]);
const stopName = stop?.name || config.stopName;
@@ -15,4 +16,4 @@ export function PassengerQr({ config, stop }: { config: Config; stop: Stop | und
<div className="qr-description"><p>Наведите камеру на QR-код</p>
<a className="passenger-link" href={url} target="_blank" rel="noreferrer"><span>{new URL(url).host}</span><ArrowUpRight size={24} /></a>
</div></div></>;
}
});

40
src/PassengerSettings.tsx Normal file
View File

@@ -0,0 +1,40 @@
import { localDate } from './lib/timetable.mjs';
import { demoPlaces } from './lib/demo';
import type { Settings, Stop, Route } from './types';
export function PassengerSettings({ settings, stops, routes, cityId, update }: { settings: Settings; stops: Stop[]; routes: Route[]; cityId: number; update: <K extends keyof Settings>(key: K, value: Settings[K]) => void }) {
const targets = settings.demo ? demoPlaces.map(stop => ({ ...stop, direction: 'демо' })) : stops;
return <div className="passenger-settings">
<h4>Важные места</h4><p className="form-hint">До трёх мест. Выберите конкретную остановку назначения и короткое название для пассажиров. Табло найдёт ближайший прямой рейс с учётом его направления и конечной.</p>
{settings.places.map((place, index) => <fieldset className="passenger-editor" key={index}><legend>Место {index + 1}</legend>
<label>Остановка назначения<select aria-label={`Остановка места ${index + 1}`} required value={place.stopId || ''} onChange={e => { const stop = targets.find(s => s.id === Number(e.target.value)); update('places', settings.places.map((item, i) => i === index ? { stopId: Number(e.target.value), name: stop?.name.slice(0, 48) || '' } : item)); }}>
<option value="">Выберите остановку</option>{!targets.some(stop => stop.id === place.stopId) && place.stopId > 0 && <option value={place.stopId}>{place.name} · сохранённая остановка</option>}
{targets.filter(stop => stop.id !== settings.stopId).map(stop => <option key={stop.id} value={stop.id} disabled={settings.places.some((p, i) => i !== index && p.stopId === stop.id)}>{stop.name}{stop.direction ? ` · ${stop.direction}` : ''}</option>)}
</select></label><label>Название на табло<input aria-label={`Название места ${index + 1}`} required maxLength={48} value={place.name} onChange={e => update('places', settings.places.map((item, i) => i === index ? { ...item, name: e.target.value } : item))} /></label>
<button type="button" className="text-button" onClick={() => update('places', settings.places.filter((_, i) => i !== index))}>Удалить место {index + 1}</button>
</fieldset>)}
<button type="button" className="text-button" disabled={settings.places.length >= 3 || !targets.length} onClick={() => update('places', [...settings.places, { stopId: 0, name: '' }])}>Добавить место</button>
<h4>Оплата поездки</h4><p className="form-hint">Основные тарифы по типу транспорта. Укажите подтверждённую цену с валютой, способы оплаты и дату проверки. Без подходящего тарифа карточка скрыта.</p>
{(['bus', 'minibus'] as const).map(type => {
const rule = settings.fareByType.find(rule => rule.type === type && rule.cityId === cityId);
const label = type === 'bus' ? 'автобуса' : 'маршрутки';
const edit = (key: 'price' | 'payment' | 'checkedAt', value: string) => update('fareByType', settings.fareByType.map(item => item.type === type && item.cityId === cityId ? { ...item, [key]: value } : item));
return <fieldset className="passenger-editor" key={type}><legend>Тариф {label}</legend>
<label className="switch-row"><span>Использовать тариф {label}</span><input type="checkbox" checked={!!rule} onChange={e => update('fareByType', [...settings.fareByType.filter(item => item.type !== type), ...(e.target.checked ? [{ cityId, type, price: '', payment: '', checkedAt: localDate(Date.now()) }] : [])])} /></label>
{rule && <><div className="form-grid"><label>Стоимость с валютой<input aria-label={`Стоимость ${label}`} required maxLength={40} value={rule.price} onChange={e => edit('price', e.target.value)} /></label><label>Проверено<input aria-label={`Дата проверки ${label}`} required type="date" max={localDate(Date.now())} value={rule.checkedAt} onChange={e => edit('checkedAt', e.target.value)} /></label></div>
<label>Способы оплаты<input aria-label={`Способы оплаты ${label}`} required maxLength={100} value={rule.payment} onChange={e => edit('payment', e.target.value)} /></label></>}
</fieldset>;
})}
<h4>Исключения для маршрутов</h4><p className="form-hint">Тариф отдельного маршрута заменяет основной тариф его типа транспорта.</p>
{settings.fareRules.map((rule, index) => <fieldset className="passenger-editor" key={index}><legend>Тариф {index + 1}</legend>
<label>Маршрут<select aria-label={`Маршрут тарифа ${index + 1}`} required value={rule.routeId || ''} onChange={e => update('fareRules', settings.fareRules.map((item, i) => i === index ? { ...item, routeId: Number(e.target.value) } : item))}>
<option value="">Выберите маршрут</option>{!routes.some(route => route.id === rule.routeId) && rule.routeId > 0 && <option value={rule.routeId}>Маршрут {rule.routeId} · сохранённый тариф</option>}
{routes.map(route => <option key={route.id} value={route.id} disabled={settings.fareRules.some((r, i) => i !== index && r.routeId === route.id)}>№{route.number} · {route.name}</option>)}
</select></label><div className="form-grid"><label>Стоимость с валютой<input aria-label={`Стоимость тарифа ${index + 1}`} required maxLength={40} placeholder="Цена с валютой" value={rule.price} onChange={e => update('fareRules', settings.fareRules.map((item, i) => i === index ? { ...item, price: e.target.value } : item))} /></label>
<label>Проверено<input aria-label={`Дата проверки тарифа ${index + 1}`} required type="date" max={localDate(Date.now())} value={rule.checkedAt} onChange={e => update('fareRules', settings.fareRules.map((item, i) => i === index ? { ...item, checkedAt: e.target.value } : item))} /></label></div>
<label>Способы оплаты<input aria-label={`Способы оплаты тарифа ${index + 1}`} required maxLength={100} value={rule.payment} onChange={e => update('fareRules', settings.fareRules.map((item, i) => i === index ? { ...item, payment: e.target.value } : item))} /></label>
<button type="button" className="text-button" onClick={() => update('fareRules', settings.fareRules.filter((_, i) => i !== index))}>Удалить тариф {index + 1}</button>
</fieldset>)}
<button type="button" className="text-button" disabled={settings.fareRules.length >= 32 || !routes.length} onClick={() => update('fareRules', [...settings.fareRules, { routeId: 0, price: '', payment: '', checkedAt: localDate(Date.now()) }])}>Добавить тариф</button>
</div>;
}

View File

@@ -1,7 +1,9 @@
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 { PassengerSettings } from './PassengerSettings';
import type { BoardData, Config, Settings, Stop } from './types';
const sections = ['Остановка', 'Оформление', 'Экраны', 'Реклама'];
@@ -83,12 +85,27 @@ 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><div className="form-grid"><label>Ночной режим<select value={draft.nightMode} onChange={event => update('nightMode', event.target.value as Settings['nightMode'])}><option value="off">Выключен</option><option value="auto">Автоматически</option></select></label><label>Вернуть обычное табло<select disabled={draft.nightMode === 'off'} value={draft.nightWakeMinutes} onChange={event => update('nightWakeMinutes', Number(event.target.value) as Settings['nightWakeMinutes'])}><option value="45">За 45 минут до рейса</option><option value="60">За 60 минут до рейса</option></select></label></div>
<p className="form-hint">Во время ночного перерыва длиннее двух часов. Обычный экран вернётся за выбранное время до первого рейса. Работает по полному расписанию; ночью остаются первый рейс, часы и уведомления.</p>
<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>)}
<label className="switch-row"><span>Транслит названий остановок<small>Латиницей над основным названием в схеме ближайшего рейса</small></span><input type="checkbox" disabled={!draft.infoServiceEnabled} checked={draft.journeyTranslitEnabled} onChange={event => update('journeyTranslitEnabled', event.target.checked)} /></label>
<label className="switch-row"><span>Напоминание о последнем рейсе<small>За 30 минут до последнего рейса направления перед ночным перерывом</small></span><input type="checkbox" checked={draft.lastTripNoticeEnabled} onChange={event => update('lastTripNoticeEnabled', event.target.checked)} /></label>
<label className="switch-row"><span>Вероятность осадков<small>Короткая подсказка в погодном блоке</small></span><input type="checkbox" checked={draft.weatherHintEnabled} onChange={event => update('weatherHintEnabled', event.target.checked)} /></label>
<PassengerSettings settings={draft} stops={stops} routes={data?.routes || []} cityId={config.cityId} update={update} />
{!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 +115,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, nightMode: 'auto', nightWakeMinutes: 60, 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>;
}

View File

@@ -2,6 +2,7 @@ import { Cloud, Moon, Rain, Snow, Storm, Sun } from './Icons';
import { NumericText } from './NumericText';
import { weatherAvailable, weatherCondition } from './lib/weather.mjs';
import type { Weather } from './types';
import { precipitationHint } from './lib/weather-hint';
const icons = { cloud: Cloud, moon: Moon, rain: Rain, snow: Snow, storm: Storm, sun: Sun };
const hourFormat = new Intl.DateTimeFormat('ru', { timeZone: 'Europe/Minsk', hour: '2-digit', minute: '2-digit' });
@@ -17,12 +18,14 @@ export function WeatherSummary({ data, now, failed }: { data: Weather | null; no
<WeatherIcon code={data.code} isDay={data.isDay} size={25} /><strong><NumericText value={degrees(data.temperature)} /></strong><span>{weatherCondition(data.code, data.isDay).label}</span>{failed && <small>сохранено</small>}
</div>;
}
export function WeatherCard({ data, now, loading, failed, demo }: { data: Weather | null; now: number; loading: boolean; failed: boolean; demo: boolean }) {
export function WeatherCard({ data, now, loading, failed, demo, hintEnabled = true }: { data: Weather | null; now: number; loading: boolean; failed: boolean; demo: boolean; hintEnabled?: boolean }) {
const available = weatherAvailable(data, now);
if (!available || !data) return <><h3>Погода</h3><p className="weather-empty">{demo ? 'Прогноз доступен для реальной остановки' : loading ? 'Получаем прогноз…' : 'Прогноз недоступен'}</p></>;
const hours = data.hourly.filter(hour => Date.parse(hour.at) > now).slice(0, 3);
const probability = hintEnabled ? precipitationHint(data, now) : null;
return <><h3>Погода</h3><div className="weather-body" data-forecast={!!hours.length}><div className="weather-now"><div className="weather-current"><WeatherIcon code={data.code} isDay={data.isDay} size={72} /><strong><NumericText value={degrees(data.temperature)} /></strong></div>
<p className="weather-description">{weatherCondition(data.code, data.isDay).label}</p></div>
{!!hours.length && <div className="weather-hours" aria-label="Прогноз на следующие три часа">{hours.map(hour => <div key={hour.at}><time dateTime={hour.at}><NumericText value={hourFormat.format(Date.parse(hour.at))} /></time><WeatherIcon code={hour.code} size={48} /><strong><NumericText value={degrees(hour.temperature)} /></strong></div>)}</div>}</div>
{probability !== null && <div className="weather-hint"><Rain size={24} /><span>Вероятность осадков в ближайший час</span><strong><NumericText value={`${probability}%`} /></strong></div>}
<div className="weather-attribution">{failed && <span>Сохранённый прогноз</span>}<a href="https://open-meteo.com/" target="_blank" rel="noreferrer">Open-Meteo · CC BY 4.0</a></div></>;
}

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useState, useSyncExternalStore } from 'react';
import type { BoardData, Config, Settings, Timetable, Weather } from './types';
import { createDemo } from './lib/demo';
import { isBoardData } from './lib/board.mjs';
@@ -62,8 +62,10 @@ export function useBoard(settings: Settings, config: Config) {
const demoTimer = setInterval(() => setData(createDemo()), 60000);
return () => clearInterval(demoTimer);
}
// A saved snapshot fills the screen while the first request is still in
// flight; that is not a connection failure until the request fails.
readSnapshot(config.cityId, settings.stopId).then(cached => {
if (!stopped && !received && cached) { setData(cached); setFailed(true); setLoading(false); setOfflineSaved(true); }
if (!stopped && !received && cached) { setData(cached); setOfflineSaved(true); }
}).catch(() => { /* Offline storage may be unavailable in private mode. */ });
async function load() {
if (running || stopped) return;
@@ -109,3 +111,11 @@ export function useClock(offset: number) {
}, [offset]);
return now;
}
export function useMediaQuery(query: string) {
return useSyncExternalStore(change => {
const list = matchMedia(query);
list.addEventListener('change', change);
return () => list.removeEventListener('change', change);
}, () => matchMedia(query).matches);
}

View File

@@ -1,9 +1,14 @@
import { localDate, 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);
export const minskDate = localDate;
export function snapshotUsable(data, now) {
return Boolean(data && !(now - Date.parse(data.updatedAt) > SNAPSHOT_MAX_MS) && data.serviceDate === minskDate(now));
}
export function upcomingDepartures(data, now, routeFilter = '') {
if (!data || now - Date.parse(data.updatedAt) > SNAPSHOT_MAX_MS || data.serviceDate !== minskDate(now)) return [];
if (!snapshotUsable(data, now)) return [];
return data.departures.filter(item => Number.isFinite(Date.parse(item.scheduledAt)) && Date.parse(item.scheduledAt) + 60000 > now && (!routeFilter || String(item.routeId) === routeFilter));
}
export function activeAlerts(alerts, now, routeFilter = '', routes = []) {
@@ -20,5 +25,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
View 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;
}

View File

@@ -1,6 +1,27 @@
import type { BoardData } from '../types';
import type { BoardData, Timetable } from '../types';
import { minskDate } from './board.mjs';
import type { Departure } from '../types';
import type { OnwardJourney } from './journey';
export function demoJourney(departure: Departure | undefined): OnwardJourney | null {
if (!departure) return null;
return { stops: ['Площадь Свободы', 'Дом торговли', 'Поликлиника', departure.destination].map((name, index) => ({ id: 1000 + index, name, minutes: [3, 6, 9, 18][index] })), more: 0 };
}
export const demoPlaces = [{ id: 1000, name: 'Площадь Свободы' }, { id: 1001, name: 'Дом торговли' }, { id: 1002, name: 'Поликлиника' }];
export function demoGuideTimetable(data: BoardData | null, stopId: number): Timetable | null {
if (!data || data.mode !== 'demo') return null;
return { version: 1, cityId: data.cityId, cityName: data.cityName, stop: { ...data.stop, id: stopId },
updatedAt: data.updatedAt, expiresAt: new Date(Date.parse(data.updatedAt) + 86400000).toISOString(), holidays: [], calendarYears: [Number(data.serviceDate.slice(0, 4))],
routes: data.routes.map(route => ({ ...route, stops: [{ id: stopId, name: data.stop.name, sequence: 0, offsetMinutes: 0 },
...demoPlaces.map((stop, i) => ({ ...stop, sequence: i + 1, offsetMinutes: (i + 1) * 3 })), { id: 1003, name: route.name, sequence: 4, offsetMinutes: 18 }],
schedules: (['weekday', 'saturday', 'sunday'] as const).map(dayType => ({ dayType, validFrom: null, validUntil: null,
trips: data.departures.filter(trip => trip.routeId === route.id).map(trip => ({ arrivalSeconds: Number(trip.scheduledTime.slice(0, 2)) * 3600 + Number(trip.scheduledTime.slice(3)) * 60,
originStopId: stopId, endStopId: 1003, destination: trip.destination, note: '', daysOfWeek: [], exceptDaysOfWeek: [] })) })) })),
};
}
const demoTime = new Intl.DateTimeFormat('ru', { timeZone: 'Europe/Minsk', hour: '2-digit', minute: '2-digit' });
export function createDemo(now = Date.now()): BoardData {
const routes = [
{ id: 42, number: '16тк', name: 'Боровуха-3 — Больница (Новка)', destination: 'Больница (Новка)', type: 'minibus' },
@@ -15,7 +36,7 @@ export function createDemo(now = Date.now()): BoardData {
const time = new Date(Math.floor(now / 60000) * 60000 + minutes * 60000);
return { id: `demo-${index}`, routeId: route.id, routeNumber: route.number, routeName: route.name,
destination: route.destination, type: route.type, scheduledAt: time.toISOString(),
scheduledTime: new Intl.DateTimeFormat('ru', { timeZone: 'Europe/Minsk', hour: '2-digit', minute: '2-digit' }).format(time),
scheduledTime: demoTime.format(time),
note: '', source: 'schedule' };
}), alerts: [], alertsAvailable: true, partial: false, updatedAt: new Date(now).toISOString(), serviceDate: minskDate(now), mode: 'demo' };
}

66
src/lib/departures.ts Normal file
View File

@@ -0,0 +1,66 @@
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 };
});
}
// Timetable and server lists are already in time order; check that once per
// list, so the clock tick can locate the boarding trip with a binary search.
export function inTimeOrder<T extends Departure>(departures: readonly T[]): readonly T[] {
const times = departures.map(departure => Date.parse(departure.scheduledAt));
if (times.every((time, index) => !index || times[index - 1] <= time)) return departures;
return departures.map((departure, index) => ({ departure, time: times[index] })).sort((a, b) => a.time - b.time).map(item => item.departure);
}
// Index of the first trip still boarding (scheduled time plus one minute).
export function boardingIndex(departures: readonly Departure[], now: number) {
let low = 0, high = departures.length;
while (low < high) {
const middle = (low + high) >> 1;
if (Date.parse(departures[middle].scheduledAt) + 60000 > now) high = middle; else low = middle + 1;
}
return low;
}
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' };
}

20
src/lib/device.mjs Normal file
View File

@@ -0,0 +1,20 @@
import { colorSchemes, lightColorSchemes } from './presentation.mjs';
import { passengerDefaults, validPlaces, validFareRules, validFareByType, confirmedPolotskFares } from './passenger-settings.mjs';
export const MANAGED_KEY = 'ridebus-board-managed-v1';
export function hydrateManagedState(value) {
if (value?.state !== 'paired' || !value.device?.settings || typeof value.device.settings !== 'object') return value;
return { ...value, device: { ...value.device, settings: { ...passengerDefaults, fareByType: value.device.cityId === 1 ? confirmedPolotskFares : [], nightMode: 'auto', nightWakeMinutes: 60, journeyTranslitEnabled: false, ...value.device.settings } } };
}
export function isManagedState(value) {
value = hydrateManagedState(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 (!['off', 'auto'].includes(s.nightMode) || ![45, 60].includes(s.nightWakeMinutes)) 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', 'journeyTranslitEnabled', 'infoPlacesEnabled', 'infoFareEnabled', 'lastTripNoticeEnabled', 'weatherHintEnabled', 'showNextSameRoute', 'longWaitEnabled', 'adsEnabled']) if (typeof s[key] !== 'boolean') return false;
if (!validPlaces(s.places) || !validFareRules(s.fareRules) || !validFareByType(s.fareByType) || s.fareByType.some(rule => rule.cityId !== device.cityId)) 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';
}

20
src/lib/information.ts Normal file
View File

@@ -0,0 +1,20 @@
import type { Scene, Settings } from '../types';
export const informationOptions = [
{ id: 'service', setting: 'infoServiceEnabled', name: 'Маршрут', label: 'Дальше по ближайшему рейсу' },
{ id: 'weather', setting: 'infoWeatherEnabled', name: 'Погода', label: 'Погода' },
{ id: 'qr', setting: 'infoQrEnabled', name: 'RideBus', label: 'QR-код расписания' },
{ id: 'places', setting: 'infoPlacesEnabled', name: 'Места', label: 'До важных мест' },
{ id: 'fare', setting: 'infoFareEnabled', name: 'Оплата', label: 'Оплата поездки' },
] as const;
export type InformationAvailable = { places: boolean; fare: boolean };
export function informationBlocks(settings: Settings, available?: InformationAvailable) {
return informationOptions.filter(block => settings[block.setting] && (block.id !== 'places' || (available?.places ?? settings.places.length > 0))
&& (block.id !== 'fare' || (available?.fare ?? settings.fareRules.length + settings.fareByType.length > 0)));
}
export function boardScenes(settings: Settings, hasAlerts: boolean, urgent: boolean, available?: InformationAvailable): Scene[] {
return ['departures', ...(hasAlerts || informationBlocks(settings, available).length ? ['info'] : []),
...(settings.adsEnabled && !urgent ? ['ad'] : [])] as Scene[];
}

23
src/lib/journey.ts Normal file
View File

@@ -0,0 +1,23 @@
import { timetableAvailable } from './timetable.mjs';
import type { Departure, Timetable } from '../types';
export type OnwardJourney = { stops: { id: number; name: string; minutes: number | null }[]; more: number };
export function onwardJourney(bundle: Timetable | null, departure: Departure | undefined, now: number, cityId: number, stopId: number): OnwardJourney | null {
if (!bundle || !departure || bundle.cityId !== cityId || bundle.stop.id !== stopId || !timetableAvailable(bundle, now)) return null;
const stops = bundle.routes.find(route => route.id === departure.routeId)?.stops;
if (!stops?.length || !departure.originStopId || !departure.endStopId) return null;
const ordered = [...stops].sort((a, b) => a.sequence - b.sequence);
const start = ordered.findIndex(stop => stop.id === departure.originStopId);
const end = ordered.findIndex(stop => stop.id === departure.endStopId);
const current = ordered.findIndex(stop => stop.id === stopId);
if (start < 0 || end < 0 || current < Math.min(start, end) || current > Math.max(start, end)) return null;
const direction = start > end ? -1 : 1;
const path = start > end ? ordered.slice(end, current).reverse() : ordered.slice(current + 1, end + 1);
const offset = ordered[current].offsetMinutes;
// Reject decreasing or incomplete offsets for duration display, keeping names.
const timed = offset !== null && path.every((stop, index) => stop.offsetMinutes !== null
&& direction * (stop.offsetMinutes - (index ? path[index - 1].offsetMinutes! : offset)) >= 0);
return { stops: path.slice(0, 4).map(stop => ({ id: stop.id, name: stop.name,
minutes: timed ? Math.ceil(direction * (stop.offsetMinutes! - offset!)) : null })), more: Math.max(0, path.length - 4) };
}

48
src/lib/night.ts Normal file
View File

@@ -0,0 +1,48 @@
import { localDate, shiftDate } from './timetable.mjs';
import type { Settings } from '../types';
import type { DisplayDeparture } from './departures';
const minute = 60000;
export function nightWindow(settings: Settings, departures: DisplayDeparture[], now: number, available: boolean) {
const inactive = { active: false as boolean, first: null as DisplayDeparture | null, completedToday: false };
if (settings.nightMode !== 'auto' || settings.demo || !available || !Number.isFinite(now)) return inactive;
const today = localDate(now), yesterday = shiftDate(today, -1);
const day = (d: DisplayDeparture) => d.serviceDate || localDate(Date.parse(d.scheduledAt));
let first: DisplayDeparture | null = null, lastToday: DisplayDeparture | undefined, lastYesterday: DisplayDeparture | undefined;
let firstAt = Infinity, firstTodayAt = Infinity, lastTodayAt = -Infinity, lastYesterdayAt = -Infinity;
const todays: { departure: DisplayDeparture; at: number }[] = [];
// The clock ticks every second; find the boundaries in one pass instead of
// re-sorting the complete three-day timetable on the TV's CPU.
for (const departure of departures) {
if (departure.status === 'cancelled' || settings.routeFilter && String(departure.routeId) !== settings.routeFilter) continue;
const at = Date.parse(departure.scheduledAt); if (!Number.isFinite(at)) continue;
const serviceDay = day(departure);
if (at + minute > now && at < firstAt) { first = departure; firstAt = at; }
if (serviceDay === today) { todays.push({ departure, at }); firstTodayAt = Math.min(firstTodayAt, at); if (at > lastTodayAt) { lastToday = departure; lastTodayAt = at; } }
if (serviceDay === yesterday && at > lastYesterdayAt) { lastYesterday = departure; lastYesterdayAt = at; }
}
const completedToday = Boolean(lastToday && lastTodayAt + minute <= now);
const beforeFirstToday = firstTodayAt > now;
// Overnight arrivals belong to their original service day. Do not close the
// stop while its final bus is still approaching or within the boarding grace.
let last = completedToday ? lastToday : beforeFirstToday && lastYesterday && lastYesterdayAt + minute <= now ? lastYesterday : undefined;
let continuation = false;
// Some carriers store their 00:xx departures as the new calendar day. Extend
// the previous closing block only through the adjacent two-hour tail, and
// require the first long break directly after it. Continuous overnight or
// daytime service cannot become a false night because of a later long gap.
if (!completedToday && lastYesterday && firstTodayAt >= lastYesterdayAt) {
let tail: DisplayDeparture | undefined, tailAt = -Infinity;
for (const item of todays) if (item.at <= lastYesterdayAt + 120 * minute && item.at > tailAt) { tail = item.departure; tailAt = item.at; }
let nextAt = Infinity;
for (const item of todays) if (item.at > tailAt) nextAt = Math.min(nextAt, item.at);
if (tail && Number.isFinite(nextAt) && nextAt - tailAt > 120 * minute && tailAt + minute <= now && now < nextAt) { last = tail; continuation = true; }
}
if (!last) return inactive;
if (!first || (!continuation && day(first) <= day(last))) return inactive;
// Classify the whole overnight break, not the time remaining at startup.
// A reload during the same break must stay in night mode until wake-up.
const gap = firstAt - Date.parse(last.scheduledAt);
if (gap <= 120 * minute || firstAt - now <= settings.nightWakeMinutes * minute) return inactive;
return { active: true, first, completedToday };
}

View File

@@ -0,0 +1,83 @@
import { localDate, shiftDate, timetableAvailable, timetableDepartures } from './timetable.mjs';
import { firstAvailable, displayDepartures } from './departures.ts';
import { activeAlerts, upcomingDepartures } from './board.mjs';
import { nightWindow } from './night.ts';
import { normalizedRouteNumber } from './cancellations.mjs';
import type { Settings, Timetable, BoardData, Departure, FareTerms, Place } from '../types';
import type { DisplayDeparture } from './departures';
type Route = Timetable['routes'][number];
type Segment = { stops: NonNullable<Route['stops']>; current: number; end: number; direction: number };
// Bundles are not mutated, so each route's stop order is sorted once, not per trip.
const ordered = new WeakMap<Route, Segment['stops']>();
function routeStops(route: Route | undefined) {
if (!route?.stops) return undefined;
let stops = ordered.get(route);
if (!stops) ordered.set(route, stops = route.stops.slice().sort((a, b) => a.sequence - b.sequence));
return stops;
}
// The actual trip endpoints matter: a route's full topology is not a promise
// that this particular bus reaches every stop, or travels in both directions.
function segment(bundle: Timetable, trip: Departure, stopId: number): Segment | null {
const stops = routeStops(bundle.routes.find(route => route.id === trip.routeId));
if (!stops?.length || !trip.originStopId || !trip.endStopId) return null;
const start = stops.findIndex(s => s.id === trip.originStopId), end = stops.findIndex(s => s.id === trip.endStopId);
const current = stops.findIndex(s => s.id === stopId);
if (start < 0 || end < 0 || current < Math.min(start, end) || current > Math.max(start, end) || start === end) return null;
return { stops, current, end, direction: Math.sign(end - start) };
}
export type PlaceTrip = { place: Place; departure: DisplayDeparture | null; minutes: number | null; arrivalAt: string | null };
export type PassengerGuide = { places: PlaceTrip[]; fare: FareTerms | null; fareRoute: { number: string; type: string } | null; lastTripId: string | null };
export function passengerGuide(settings: Settings, bundle: Timetable | null, data: BoardData | null, now: number, cityId: number, reliableAlerts: boolean): PassengerGuide {
const result: PassengerGuide = { places: [], fare: null, fareRoute: null, lastTripId: null };
if (data && data.mode !== 'demo' && (data.cityId !== cityId || data.stop.id !== settings.stopId)) data = null;
const available = Boolean(bundle && bundle.cityId === cityId && bundle.stop.id === settings.stopId && timetableAvailable(bundle, now));
const date = localDate(now);
const raw = available ? timetableDepartures(bundle, date) as Departure[] : upcomingDepartures(data, now, settings.routeFilter) as Departure[];
const alerts = activeAlerts(data?.alerts || [], now, settings.routeFilter, data?.routes || []);
const all = displayDepartures(raw, alerts, settings.stopId, now, reliableAlerts);
const upcoming = all.filter(trip => Date.parse(trip.scheduledAt) + 60000 > now && (!settings.routeFilter || String(trip.routeId) === settings.routeFilter));
const first = firstAvailable(upcoming);
// A future verification date must not publish a tariff early.
result.fare = settings.infoFareEnabled && first ? settings.fareRules.find(rule => rule.routeId === first.routeId && rule.checkedAt <= date)
|| settings.fareByType.find(rule => rule.cityId === cityId && rule.type === first.type && rule.checkedAt <= date) || null : null;
// The fare applies to this trip's route; the card names it.
result.fareRoute = result.fare && first ? { number: first.routeNumber, type: first.type } : null;
if (!available || !bundle) return result;
if (settings.infoPlacesEnabled && settings.places.length && bundle.routes.some(route => route.stops?.length)) {
result.places = settings.places.filter(place => place.stopId !== settings.stopId).map(place => {
for (const trip of upcoming) {
if (trip.status === 'cancelled') continue;
const path = segment(bundle, trip, settings.stopId);
if (!path) continue;
const target = path.stops.findIndex(stop => stop.id === place.stopId);
if (target < 0 || path.direction * (target - path.current) <= 0 || path.direction * (path.end - target) < 0) continue;
const span = path.direction > 0 ? path.stops.slice(path.current, target + 1) : path.stops.slice(target, path.current + 1).reverse();
const timed = span.every((stop, i) => stop.offsetMinutes !== null && (!i || span[i - 1].offsetMinutes !== null
&& path.direction * (stop.offsetMinutes - span[i - 1].offsetMinutes!) >= 0));
const minutes = timed ? Math.ceil(path.direction * (path.stops[target].offsetMinutes! - path.stops[path.current].offsetMinutes!)) : null;
return { place, departure: trip, minutes, arrivalAt: minutes === null ? null : new Date(Date.parse(trip.scheduledAt) + minutes * 60000).toISOString() };
}
return { place, departure: null, minutes: null, arrivalAt: null };
});
}
if (!settings.lastTripNoticeEnabled || settings.demo || !first || Date.parse(first.scheduledAt) - now > 30 * 60000) return result;
const firstPath = segment(bundle, first, settings.stopId);
if (!firstPath) return result;
const closingDate = localDate(Date.parse(first.scheduledAt));
if (![-1, 0, 1].every(days => bundle.calendarYears.includes(Number(shiftDate(closingDate, days).slice(0, 4))))) return result;
// Cancellations only match trips with the same route number, so the narrower
// pool resolves them exactly as the complete two-day timetable would.
const number = normalizedRouteNumber(first.routeNumber);
const pool = [...new Map([...timetableDepartures(bundle, shiftDate(closingDate, -1)), ...timetableDepartures(bundle, closingDate)]
.filter(trip => normalizedRouteNumber(trip.routeNumber) === number).map(trip => [trip.id, trip])).values()] as Departure[];
const directionTrips = displayDepartures(pool, alerts, settings.stopId, now, reliableAlerts).filter(trip =>
trip.routeId === first.routeId && segment(bundle, trip, settings.stopId)?.direction === firstPath.direction);
// Reuse the overnight boundary policy, including after-midnight service tails.
// Looking just after boarding proves that this trip starts the closing gap.
const closing = nightWindow({ ...settings, demo: false, nightMode: 'auto', nightWakeMinutes: 60, routeFilter: String(first.routeId) },
directionTrips, Date.parse(first.scheduledAt) + 60001, true);
if (closing.active) result.lastTripId = first.id;
return result;
}

View File

@@ -0,0 +1,30 @@
import { validDate } from './timetable.mjs';
const object = value => value && typeof value === 'object' && !Array.isArray(value);
const text = (value, max) => typeof value === 'string' && value.trim().length > 0 && value.length <= max;
const id = (value, max = 1000000) => Number.isSafeInteger(value) && value > 0 && value <= max;
export function validPlaces(value) {
return Array.isArray(value) && value.length <= 3 && new Set(value.map(item => item?.stopId)).size === value.length
&& value.every(item => object(item) && Object.keys(item).every(key => ['stopId', 'name'].includes(key))
&& id(item.stopId) && text(item.name, 48));
}
export function validFareRules(value) {
return Array.isArray(value) && value.length <= 32 && new Set(value.map(item => item?.routeId)).size === value.length
&& value.every(item => object(item) && Object.keys(item).every(key => ['routeId', 'price', 'payment', 'checkedAt'].includes(key))
&& id(item.routeId, 999999999) && text(item.price, 40) && text(item.payment, 100) && validDate(item.checkedAt));
}
export function validFareByType(value) {
return Array.isArray(value) && value.length <= 2 && new Set(value.map(item => item?.type)).size === value.length
&& value.every(item => object(item) && Object.keys(item).every(key => ['cityId', 'type', 'price', 'payment', 'checkedAt'].includes(key))
&& id(item.cityId) && ['bus', 'minibus'].includes(item.type) && text(item.price, 40) && text(item.payment, 100) && validDate(item.checkedAt));
}
// Confirmed by the operator on 2026-10-11 for Polotsk, not a provider estimate.
// Keep that verification date fixed and bind the values to the actual city.
export const confirmedPolotskFares = [
{ cityId: 1, type: 'bus', price: '1,05 BYN', payment: 'QR-кодом или наличными', checkedAt: '2026-10-11' },
{ cityId: 1, type: 'minibus', price: '2,10 BYN', payment: 'QR-кодом или наличными', checkedAt: '2026-10-11' },
];
export const passengerDefaults = {
infoPlacesEnabled: true, infoFareEnabled: true, lastTripNoticeEnabled: true, weatherHintEnabled: true,
places: [], fareRules: [], fareByType: [],
};

View File

@@ -4,10 +4,20 @@ import { colorScheme } from './presentation.mjs';
import { isTimetable } from './timetable.mjs';
import { isWeather } from './weather.mjs';
import type { Timetable, Weather } from '../types';
import { validPlaces, validFareRules, validFareByType, confirmedPolotskFares } from './passenger-settings.mjs';
import type { FareTypeRule } 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,
journeyTranslitEnabled: false,
infoPlacesEnabled: true, infoFareEnabled: true, lastTripNoticeEnabled: true, weatherHintEnabled: true, places: [], fareRules: [],
fareByType: [{ cityId: 1, type: 'bus', price: '1,05 BYN', payment: 'QR-кодом или наличными', checkedAt: '2026-10-11' },
{ cityId: 1, type: 'minibus', price: '2,10 BYN', payment: 'QR-кодом или наличными', checkedAt: '2026-10-11' }],
showNextSameRoute: true, longWaitEnabled: true, displayProfile: 'tv', viewingDistance: 'near',
nightMode: 'auto', nightWakeMinutes: 60,
adsEnabled: false, adSeconds: 7, adTitle: 'Ваше место в городе', adText: 'Короткая история. Большая аудитория.' };
export function readSettings(): Settings {
try {
const saved = JSON.parse(localStorage.getItem(SETTINGS_KEY) || '{}');
@@ -16,6 +26,15 @@ 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,
journeyTranslitEnabled: saved.journeyTranslitEnabled === true,
infoPlacesEnabled: saved.infoPlacesEnabled !== false, infoFareEnabled: saved.infoFareEnabled !== false,
lastTripNoticeEnabled: saved.lastTripNoticeEnabled !== false, weatherHintEnabled: saved.weatherHintEnabled !== false,
places: validPlaces(saved.places) ? saved.places : [], fareRules: validFareRules(saved.fareRules) ? saved.fareRules : [],
fareByType: saved.fareByType === undefined ? confirmedPolotskFares as FareTypeRule[] : validFareByType(saved.fareByType) ? saved.fareByType : [],
showNextSameRoute: saved.showNextSameRoute !== false, longWaitEnabled: saved.longWaitEnabled !== false,
displayProfile: saved.displayProfile === 'street' ? 'street' : 'tv', viewingDistance: ['medium', 'far'].includes(saved.viewingDistance) ? saved.viewingDistance : 'near',
nightMode: saved.nightMode === 'off' ? 'off' : 'auto', nightWakeMinutes: saved.nightWakeMinutes === 45 ? 45 : 60,
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 };

View File

@@ -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`))
@@ -11,40 +12,65 @@ export function isTimetable(value, cityId, stopId) {
&& Array.isArray(value.calendarYears) && value.calendarYears.every(Number.isInteger)
&& Array.isArray(value.holidays) && value.holidays.every(validDate)
&& Array.isArray(value.routes) && value.routes.every(route => Number.isSafeInteger(route.id) && route.id > 0 && typeof route.number === 'string'
&& (route.stops === undefined || Array.isArray(route.stops) && route.stops.every(stop => Number.isSafeInteger(stop.id) && stop.id > 0
&& typeof stop.name === 'string' && stop.name.trim() && Number.isSafeInteger(stop.sequence) && stop.sequence >= 0
&& (stop.offsetMinutes === null || typeof stop.offsetMinutes === 'number' && Number.isFinite(stop.offsetMinutes) && stop.offsetMinutes >= 0 && stop.offsetMinutes < 1440))
&& new Set(route.stops.map(stop => stop.id)).size === route.stops.length && new Set(route.stops.map(stop => stop.sequence)).size === route.stops.length)
&& typeof route.name === 'string' && Array.isArray(route.schedules) && route.schedules.every(schedule =>
['weekday', 'saturday', 'sunday', 'holiday'].includes(schedule.dayType) && (!schedule.validFrom || validDate(schedule.validFrom))
&& (!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.endStopId == null || Number.isSafeInteger(trip.endStopId) && trip.endStopId > 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) {
return Boolean(bundle && now >= Date.parse(bundle.updatedAt) - 60000 && now < Date.parse(bundle.expiresAt)
&& now - Date.parse(bundle.updatedAt) <= TIMETABLE_MAX_MS && bundle.calendarYears.includes(Number(localDate(now).slice(0, 4))));
}
const routeOrder = new Intl.Collator('ru', { numeric: true });
// Expand civil service dates, retaining the next date for arrivals past midnight.
export function timetableDepartures(bundle, date) {
if (!bundle) return [];
function expandTimetable(bundle, date) {
const result = [];
const holidays = new Set(bundle.holidays);
const tomorrow = shiftDate(date, 1);
for (let day = -1; day <= 1; day++) {
const serviceDate = shiftDate(date, day);
if (!bundle.calendarYears.includes(Number(serviceDate.slice(0, 4)))) continue;
const weekday = new Date(`${serviceDate}T12:00:00Z`).getUTCDay() || 7;
const dayType = holidays.has(serviceDate) ? 'holiday' : weekday === 7 ? 'sunday' : weekday === 6 ? 'saturday' : 'weekday';
const midnight = Date.parse(`${serviceDate}T00:00:00+03:00`);
for (const route of bundle.routes) {
const eligible = route.schedules.filter(schedule => (!schedule.validFrom || schedule.validFrom <= serviceDate) && (!schedule.validUntil || schedule.validUntil >= serviceDate));
const schedule = eligible.find(schedule => schedule.dayType === dayType) || (dayType === 'holiday' ? eligible.find(schedule => schedule.dayType === 'sunday') : null);
for (const [index, trip] of (schedule?.trips || []).entries()) {
if (trip.daysOfWeek.length && !trip.daysOfWeek.includes(weekday) || trip.exceptDaysOfWeek.includes(weekday)) continue;
const scheduledAt = new Date(Date.parse(`${serviceDate}T00:00:00+03:00`) + trip.arrivalSeconds * 1000).toISOString();
const arrivalDate = localDate(Date.parse(scheduledAt));
if (arrivalDate < date || arrivalDate > shiftDate(date, 1)) continue;
result.push({ id: `${route.id}:${serviceDate}:${index}:${scheduledAt}`, routeId: route.id, routeNumber: route.number,
const at = midnight + trip.arrivalSeconds * 1000;
const arrivalDate = localDate(at);
if (arrivalDate < date || arrivalDate > tomorrow) continue;
const scheduledAt = new Date(at).toISOString();
result.push({ at, departure: { id: `${route.id}:${serviceDate}:${index}:${scheduledAt}`, routeId: route.id, routeNumber: route.number,
routeName: route.name, destination: trip.destination, type: route.type, scheduledAt,
scheduledTime: new Date(Date.parse(scheduledAt) + 10800000).toISOString().slice(11, 16), note: trip.note, source: 'schedule' });
serviceDate, originStopId: trip.originStopId, originTime: trip.originTime, endStopId: trip.endStopId,
scheduledTime: new Date(at + 10800000).toISOString().slice(11, 16), note: trip.note, source: 'schedule' } });
}
}
}
return result.sort((a, b) => Date.parse(a.scheduledAt) - Date.parse(b.scheduledAt) || a.routeNumber.localeCompare(b.routeNumber, 'ru', { numeric: true }));
return result.sort((a, b) => a.at - b.at || routeOrder.compare(a.departure.routeNumber, b.departure.routeNumber)).map(item => item.departure);
}
// A loaded bundle is never mutated. The board, night mode and passenger guide
// expand the same few dates, so share one frozen result per bundle and date.
const expanded = new WeakMap();
export function timetableDepartures(bundle, date) {
if (!bundle) return [];
let dates = expanded.get(bundle);
if (!dates) expanded.set(bundle, dates = new Map());
let result = dates.get(date);
if (!result) {
if (dates.size >= 4) dates.delete(dates.keys().next().value);
dates.set(date, result = Object.freeze(expandTimetable(bundle, date)));
}
return result;
}

Some files were not shown because too many files have changed in this diff Show More