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.
13
README.md
@@ -22,9 +22,10 @@ npm start
|
|||||||
- Точная отмена выделяется непосредственно в строке рейса: время остаётся видимым, отсчёт заменяется пометкой «Отменён». Ближайшим становится следующий доступный рейс. Общие сообщения без однозначного времени и остановки показывают «Есть изменения» и сохраняют исходный текст предупреждения.
|
- Точная отмена выделяется непосредственно в строке рейса: время остаётся видимым, отсчёт заменяется пометкой «Отменён». Ближайшим становится следующий доступный рейс. Общие сообщения без однозначного времени и остановки показывают «Есть изменения» и сохраняют исходный текст предупреждения.
|
||||||
- Под ближайшим отправлением — время следующего рейса того же маршрута в том же направлении. Отменённые отправления пропускаются, завтрашняя дата отмечается один раз. Переключатель находится в настройках → «Экраны».
|
- Под ближайшим отправлением — время следующего рейса того же маршрута в том же направлении. Отменённые отправления пропускаются, завтрашняя дата отмечается один раз. Переключатель находится в настройках → «Экраны».
|
||||||
- При ожидании от 90 минут крупно показывается фактическое время отправления с подписью «Сегодня в» или «Завтра в». При приближении рейса возвращается обычный отсчёт. Режим можно отключить в настройках → «Экраны».
|
- При ожидании от 90 минут крупно показывается фактическое время отправления с подписью «Сегодня в» или «Завтра в». При приближении рейса возвращается обычный отсчёт. Режим можно отключить в настройках → «Экраны».
|
||||||
|
- Автоматический ночной режим включается после завершения движения по полному расписанию, если весь ночной перерыв до первого следующего рейса длиннее двух часов. На чёрном фоне — первый рейс, направление, часы без секунд и актуальные уведомления. Информация и реклама не сменяют этот экран. При оставшихся 60 минутах возвращается обычное табло; в настройках → «Экраны» можно выбрать 45 минут или выключить режим. Дневная тема и палитра восстанавливаются автоматически. По умолчанию режим автоматический, возврат за 60 минут.
|
||||||
- Настройки → «Оформление» → «Читаемость»: профили «Телевизор» / «Уличное табло» и расстояние «Близко» / «Средне» / «Далеко». Для улицы и дальнего просмотра текст крупнее, одновременно видимых строк меньше. Уличный профиль использует спокойное короткое появление вместо вертикальной прокрутки текста. Информационные карточки при дальнем просмотре показываются по одной.
|
- Настройки → «Оформление» → «Читаемость»: профили «Телевизор» / «Уличное табло» и расстояние «Близко» / «Средне» / «Далеко». Для улицы и дальнего просмотра текст крупнее, одновременно видимых строк меньше. Уличный профиль использует спокойное короткое появление вместо вертикальной прокрутки текста. Информационные карточки при дальнем просмотре показываются по одной.
|
||||||
- В шапке — название и направление конкретной площадки из API, часы и дата в `Europe/Minsk`. Поле «основная» не выдаётся за направление; если направления нет, оно не придумывается. Город, подпись «Остановка», «RideBus API» и «Все направления» убраны из шапки.
|
- В шапке — название и направление конкретной площадки из API, часы и дата в `Europe/Minsk`. Поле «основная» не выдаётся за направление; если направления нет, оно не придумывается. Город, подпись «Остановка», «RideBus API» и «Все направления» убраны из шапки.
|
||||||
- Информационный экран: первый и последний рейс, погода, QR-код и предупреждения. Карточка движения показывает последний рейс сегодня и первый завтра с номером и направлением по полному расписанию, с учётом фильтра маршрута и прибытий после полуночи. Без полного расписания время не подставляется из короткого списка ближайших рейсов. Часы и ближайший рейс остаются видимыми. В вертикальной и короткой компоновке карточки сменяются при следующих посещениях экрана; их также можно выбрать кнопками «Движение», «Погода», «RideBus».
|
- Информационный экран: «Дальше по маршруту», погода, QR-код и предупреждения. Схема показывает до четырёх следующих остановок ближайшего доступного рейса и время пути от текущей остановки по расписанию. Учитывает выбранный маршрут, отмены, укороченные и обратные рейсы; на конечной не придумывает продолжение. Неизвестное время отмечено «—». Горизонтальная линия плавно сдвигается влево, выделенное название остановки увеличивается, соседние остаются компактными — по мотивам [инфосистемы «Квант»](https://www.artlebedev.ru/kvant/moscow/). Это обзор поездки после посадки, а не положение автобуса: остановки сменяются каждые 4 секунды (6 для уличного профиля), сцена автоматически удлиняется до полного обзора. В узкой карточке название и схема подстраиваются под доступное место. Часы и ближайший рейс остаются видимыми. В вертикальной и короткой компоновке карточки сменяются при следующих посещениях экрана; их также можно выбрать кнопками «Маршрут», «Погода», «RideBus».
|
||||||
- В настройках → «Экраны» → «Блоки информации» каждый из трёх блоков можно включить или отключить. Оставшиеся карточки занимают доступную ширину, отключённые пропускаются в компактном цикле. При отключении всех блоков информационный экран исключается из обычного цикла; активные предупреждения о движении всё равно показываются. Выбор сохраняется в браузере; «Сбросить цикл» включает все блоки.
|
- В настройках → «Экраны» → «Блоки информации» каждый из трёх блоков можно включить или отключить. Оставшиеся карточки занимают доступную ширину, отключённые пропускаются в компактном цикле. При отключении всех блоков информационный экран исключается из обычного цикла; активные предупреждения о движении всё равно показываются. Выбор сохраняется в браузере; «Сбросить цикл» включает все блоки.
|
||||||
- Уведомления об отменах, объездах, задержках и другие сообщения оформлены простой карточкой с иконкой, номерами маршрутов и сроком действия. Исходные заголовки, тексты и детали отмен сохраняются. Статус предупреждений отображается только при их недоступности.
|
- Уведомления об отменах, объездах, задержках и другие сообщения оформлены простой карточкой с иконкой, номерами маршрутов и сроком действия. Исходные заголовки, тексты и детали отмен сохраняются. Статус предупреждений отображается только при их недоступности.
|
||||||
- Цикл 25 секунд / 8 секунд, настраиваемый. До ближайшего рейса ≤2 минут главный экран удерживается; можно отключить в настройках. Отмены и объезды исключают рекламную сцену из цикла.
|
- Цикл 25 секунд / 8 секунд, настраиваемый. До ближайшего рейса ≤2 минут главный экран удерживается; можно отключить в настройках. Отмены и объезды исключают рекламную сцену из цикла.
|
||||||
@@ -97,3 +98,13 @@ docker compose up --build -d
|
|||||||
На Linux-плеере откройте страницу Chromium в kiosk mode и настройте автозапуск графической сессии. Пример службы — `deploy/ridebus-kiosk.service`; пути Chromium и переменные дисплея зависят от ОС. `Restart=always` восстанавливает процесс браузера. Docker восстанавливает сервер после перезагрузки. Настройку и испытание на физическом плеере нужно выполнить отдельно: служба не обнаруживает зависший renderer, а удалённый мониторинг устройств в MVP отсутствует.
|
На Linux-плеере откройте страницу Chromium в kiosk mode и настройте автозапуск графической сессии. Пример службы — `deploy/ridebus-kiosk.service`; пути Chromium и переменные дисплея зависят от ОС. `Restart=always` восстанавливает процесс браузера. Docker восстанавливает сервер после перезагрузки. Настройку и испытание на физическом плеере нужно выполнить отдельно: служба не обнаруживает зависший renderer, а удалённый мониторинг устройств в MVP отсутствует.
|
||||||
|
|
||||||
Изменения кода выкладывайте целой сборкой; Service Worker загружает новую оболочку в фоне, активируя обновление при следующем закрытии/перезапуске табло.
|
Изменения кода выкладывайте целой сборкой; 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`, поэтому существующие настройки локального и удалённого управления совместимы; миграция БД не требуется.
|
||||||
|
|||||||
BIN
artifacts/journey-1079x591.jpg
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
artifacts/journey-390x667.jpg
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
artifacts/journey-879x593.jpg
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
artifacts/journey-kvant-1079x591.jpg
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
artifacts/journey-kvant-390x667.jpg
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
artifacts/journey-kvant-879x593.jpg
Normal file
|
After Width: | Height: | Size: 51 KiB |
BIN
artifacts/journey-kvant-focus-three.jpg
Normal file
|
After Width: | Height: | Size: 152 KiB |
BIN
artifacts/journey-kvant-light.jpg
Normal file
|
After Width: | Height: | Size: 105 KiB |
BIN
artifacts/journey-kvant-portrait-long.jpg
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
artifacts/journey-kvant-repaint.jpg
Normal file
|
After Width: | Height: | Size: 128 KiB |
BIN
artifacts/journey-kvant-three-cards.jpg
Normal file
|
After Width: | Height: | Size: 149 KiB |
BIN
artifacts/journey-kvant-wide.jpg
Normal file
|
After Width: | Height: | Size: 123 KiB |
BIN
artifacts/journey-light.jpg
Normal file
|
After Width: | Height: | Size: 153 KiB |
BIN
artifacts/journey-portrait-last-page.jpg
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
artifacts/journey-three-cards.jpg
Normal file
|
After Width: | Height: | Size: 153 KiB |
BIN
artifacts/night-1280x900.jpg
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
artifacts/night-1920x1080.jpg
Normal file
|
After Width: | Height: | Size: 108 KiB |
BIN
artifacts/night-390x667.jpg
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
artifacts/night-879x593.jpg
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
artifacts/night-admin-settings.jpg
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
artifacts/night-avtovokzal-reload.jpg
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
artifacts/night-live.jpg
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
artifacts/night-no-qr.jpg
Normal file
|
After Width: | Height: | Size: 51 KiB |
BIN
artifacts/night-wake-light.jpg
Normal file
|
After Width: | Height: | Size: 44 KiB |
19
artifacts/verification-journey.md
Normal 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 сброшен.
|
||||||
39
artifacts/verification-night-mode.md
Normal 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 обоих проектов обновлены. Блок первого и последнего рейса в «Информации» пока не заменён: пользователю предложена схема следующих остановок ближайшего автобуса.
|
||||||
@@ -1,6 +1,19 @@
|
|||||||
import { ApiError } from './board.mjs';
|
import { ApiError } from './board.mjs';
|
||||||
import { TIMETABLE_MAX_MS, localDate, shiftDate, validDate } from '../src/lib/timetable.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) {
|
function normalizeSchedules(raw, route, rawStops, stopId) {
|
||||||
if (!Array.isArray(raw) || !Array.isArray(rawStops)) throw new ApiError();
|
if (!Array.isArray(raw) || !Array.isArray(rawStops)) throw new ApiError();
|
||||||
const stops = new Map(rawStops.map(stop => [Number(stop.stop_id), stop]));
|
const stops = new Map(rawStops.map(stop => [Number(stop.stop_id), stop]));
|
||||||
@@ -35,7 +48,7 @@ function normalizeSchedules(raw, route, rawStops, stopId) {
|
|||||||
const terminal = end || [...rawStops].sort((a, b) => Number(b.sequence) - Number(a.sequence))[0];
|
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];
|
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,
|
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]}`,
|
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,
|
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) }];
|
note: typeof trip.note === 'string' ? trip.note.trim() : '', daysOfWeek: days(trip.daysOfWeek), exceptDaysOfWeek: days(trip.exceptDaysOfWeek) }];
|
||||||
});
|
});
|
||||||
@@ -67,7 +80,7 @@ export async function loadTimetable({ upstream, cityId, cityName, stop, now }) {
|
|||||||
while (index < routes.length) {
|
while (index < routes.length) {
|
||||||
const current = index++, route = routes[current];
|
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`)]);
|
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() || '' },
|
return { version: 1, cityId, cityName, stop: { id: Number(stop.id), name: stop.name.trim(), direction: stop.description?.trim() || '' },
|
||||||
|
|||||||
49
src/App.tsx
@@ -3,14 +3,16 @@ import { flushSync } from 'react-dom';
|
|||||||
import { ArrowDown, ArrowRight, ArrowUpRight, BusFront, Clock, Close, 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 { NumericText } from './NumericText';
|
||||||
import { RouteBadge } from './RouteBadge';
|
import { RouteBadge } from './RouteBadge';
|
||||||
import { ServiceWindow } from './ServiceWindow';
|
import { NextStops } from './NextStops';
|
||||||
import { AlertCard } from './AlertCard';
|
import { AlertCard } from './AlertCard';
|
||||||
import { FittedDestination } from './FittedDestination';
|
import { FittedDestination } from './FittedDestination';
|
||||||
import { useIdleControls } from './useIdleControls';
|
import { useIdleControls } from './useIdleControls';
|
||||||
import { PassengerQr } from './PassengerQr';
|
import { PassengerQr } from './PassengerQr';
|
||||||
import { WeatherCard, WeatherSummary } from './WeatherBlock';
|
import { WeatherCard, WeatherSummary } from './WeatherBlock';
|
||||||
import { localDate, shiftDate, timetableAvailable, timetableDepartures } from './lib/timetable.mjs';
|
import { localDate, shiftDate, timetableAvailable, timetableDepartures } from './lib/timetable.mjs';
|
||||||
import { boardScenes, informationBlocks, serviceWindow } from './lib/information';
|
import { boardScenes, informationBlocks } from './lib/information';
|
||||||
|
import { onwardJourney } from './lib/journey';
|
||||||
|
import { demoJourney } from './lib/demo';
|
||||||
import { displayDepartures, firstAvailable, longWait, nextSameRoute, readability } from './lib/departures';
|
import { displayDepartures, firstAvailable, longWait, nextSameRoute, readability } from './lib/departures';
|
||||||
import type { DisplayDeparture } from './lib/departures';
|
import type { DisplayDeparture } from './lib/departures';
|
||||||
import logo from './assets/ridebus-logo.svg?raw';
|
import logo from './assets/ridebus-logo.svg?raw';
|
||||||
@@ -24,6 +26,8 @@ import { SettingsPanel } from './SettingsPanel';
|
|||||||
import { useDeviceControl } from './useDeviceControl';
|
import { useDeviceControl } from './useDeviceControl';
|
||||||
import type { ManagedState } from './useDeviceControl';
|
import type { ManagedState } from './useDeviceControl';
|
||||||
import { DeviceSetup, ManagedPanel } from './DeviceSetup';
|
import { DeviceSetup, ManagedPanel } from './DeviceSetup';
|
||||||
|
import { nightWindow } from './lib/night';
|
||||||
|
import { NightScreen } from './NightScreen';
|
||||||
|
|
||||||
const clockFormat = new Intl.DateTimeFormat('ru', { timeZone: 'Europe/Minsk', hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
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 dateFormat = new Intl.DateTimeFormat('ru', { timeZone: 'Europe/Minsk', weekday: 'long', day: 'numeric', month: 'long' });
|
||||||
@@ -100,13 +104,14 @@ export function DeparturesScreen({ data, departures, now, alerts, loading, rowCo
|
|||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function InfoScreen({ data, alerts, fresh, seconds, paused, page, onPage, config, weather, now, settings, service }: { data: BoardData | null; alerts: Alert[]; fresh: boolean; seconds: number; paused: boolean; page: number; onPage: (page: number) => void;
|
function InfoScreen({ data, alerts, fresh, seconds, paused, page, onPage, config, weather, now, settings, journey, onJourneyPages }: { data: BoardData | null; alerts: Alert[]; fresh: boolean; seconds: number; paused: boolean; page: number; onPage: (page: number) => void;
|
||||||
config: Config; weather: ReturnType<typeof useWeather>; now: number; settings: Settings; service: ComponentProps<typeof ServiceWindow> }) {
|
config: Config; weather: ReturnType<typeof useWeather>; now: number; settings: Settings; journey: Omit<ComponentProps<typeof NextStops>, 'active' | 'onPages'>; onJourneyPages: (pages: number) => void }) {
|
||||||
const panel = useRef<HTMLElement>(null);
|
const panel = useRef<HTMLElement>(null);
|
||||||
const grid = useRef<HTMLDivElement>(null);
|
const grid = useRef<HTMLDivElement>(null);
|
||||||
const [compact, setCompact] = useState(false);
|
const [compact, setCompact] = useState(false);
|
||||||
const blocks = informationBlocks(settings);
|
const blocks = informationBlocks(settings);
|
||||||
const activeBlock = blocks[page % blocks.length]?.id;
|
const activeBlock = blocks[page % blocks.length]?.id;
|
||||||
|
useEffect(() => { if (alerts.length || !settings.infoServiceEnabled || compact && activeBlock !== 'service') onJourneyPages(1); }, [alerts.length, settings.infoServiceEnabled, compact, activeBlock, onJourneyPages]);
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!grid.current) return;
|
if (!grid.current) return;
|
||||||
const observer = new ResizeObserver(entries => { const { width, height } = entries[0].contentRect; setCompact(width < 1000 || innerHeight > innerWidth || innerHeight < 650 || height < 330 || settings.displayProfile === 'street' || settings.viewingDistance === 'far'); });
|
const observer = new ResizeObserver(entries => { const { width, height } = entries[0].contentRect; setCompact(width < 1000 || innerHeight > innerWidth || innerHeight < 650 || height < 330 || settings.displayProfile === 'street' || settings.viewingDistance === 'far'); });
|
||||||
@@ -129,7 +134,7 @@ function InfoScreen({ data, alerts, fresh, seconds, paused, page, onPage, config
|
|||||||
{!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 && 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">
|
{alerts.length ? <div className="info-notices">{alerts.map(alert => <AlertCard key={alert.id} alert={alert} />)}</div> : <div ref={grid} className="info-grid">
|
||||||
{blocks.map(block => <section key={block.id} className={`info-card ${block.id}-info`} data-active={activeBlock === block.id}>
|
{blocks.map(block => <section key={block.id} className={`info-card ${block.id}-info`} data-active={activeBlock === block.id}>
|
||||||
{block.id === 'service' && <ServiceWindow {...service} />}
|
{block.id === 'service' && <NextStops key={journey.departure?.id || 'none'} {...journey} active={!compact || activeBlock === 'service'} onPages={onJourneyPages} />}
|
||||||
{block.id === 'weather' && <WeatherCard data={weather.data} now={now} loading={weather.loading} failed={weather.failed} demo={data?.mode === 'demo'} />}
|
{block.id === 'weather' && <WeatherCard data={weather.data} now={now} loading={weather.loading} failed={weather.failed} demo={data?.mode === 'demo'} />}
|
||||||
{block.id === 'qr' && <PassengerQr config={config} stop={data?.stop} />}
|
{block.id === 'qr' && <PassengerQr config={config} stop={data?.stop} />}
|
||||||
</section>)}
|
</section>)}
|
||||||
@@ -158,6 +163,7 @@ function BoardApplication({ management }: { management?: { state: ManagedState;
|
|||||||
const controlsVisible = useIdleControls(settingsOpen);
|
const controlsVisible = useIdleControls(settingsOpen);
|
||||||
const [scene, setScene] = useState<Scene>('departures');
|
const [scene, setScene] = useState<Scene>('departures');
|
||||||
const [infoPage, setInfoPage] = useState(0);
|
const [infoPage, setInfoPage] = useState(0);
|
||||||
|
const [journeyPages, setJourneyPages] = useState(1);
|
||||||
const [paused, setPaused] = useState(false);
|
const [paused, setPaused] = useState(false);
|
||||||
const [startedAt, setStartedAt] = useState(Date.now());
|
const [startedAt, setStartedAt] = useState(Date.now());
|
||||||
const [notice, setNotice] = useState('');
|
const [notice, setNotice] = useState('');
|
||||||
@@ -169,6 +175,7 @@ function BoardApplication({ management }: { management?: { state: ManagedState;
|
|||||||
const date = localDate(now);
|
const date = localDate(now);
|
||||||
const fullDepartures = useMemo(() => timetableDepartures(timetable.data, date) as Departure[], [timetable.data, date]);
|
const fullDepartures = useMemo(() => timetableDepartures(timetable.data, date) as Departure[], [timetable.data, date]);
|
||||||
const fullAvailable = !settings.demo && timetableAvailable(timetable.data, now);
|
const fullAvailable = !settings.demo && timetableAvailable(timetable.data, now);
|
||||||
|
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[] : [], [settings.nightMode, timetable.data, date, fullDepartures]);
|
||||||
const data: BoardData | null = liveData || (fullAvailable && timetable.data ? { ...timetable.data, departures: fullDepartures, alerts: [], alertsAvailable: false, partial: false, serviceDate: date, mode: 'live' } : null);
|
const data: BoardData | null = liveData || (fullAvailable && timetable.data ? { ...timetable.data, departures: fullDepartures, alerts: [], alertsAvailable: false, partial: false, serviceDate: date, mode: 'live' } : null);
|
||||||
const rawDepartures: Departure[] = fullAvailable ? fullDepartures.filter(item => Date.parse(item.scheduledAt) + 60000 > now && (!settings.routeFilter || String(item.routeId) === settings.routeFilter)) : upcomingDepartures(data, now, settings.routeFilter);
|
const rawDepartures: Departure[] = fullAvailable ? fullDepartures.filter(item => Date.parse(item.scheduledAt) + 60000 > now && (!settings.routeFilter || String(item.routeId) === settings.routeFilter)) : upcomingDepartures(data, now, settings.routeFilter);
|
||||||
const alerts: Alert[] = activeAlerts(data?.alerts || [], now, settings.routeFilter, data?.routes || []);
|
const alerts: Alert[] = activeAlerts(data?.alerts || [], now, settings.routeFilter, data?.routes || []);
|
||||||
@@ -179,17 +186,19 @@ function BoardApplication({ management }: { management?: { state: ManagedState;
|
|||||||
const statusById = new Map(allDepartures.map(item => [item.id, item]));
|
const statusById = new Map(allDepartures.map(item => [item.id, item]));
|
||||||
const departures = rawDepartures.map(item => statusById.get(item.id)!);
|
const departures = rawDepartures.map(item => statusById.get(item.id)!);
|
||||||
const first = firstAvailable(departures);
|
const first = firstAvailable(departures);
|
||||||
const service = serviceWindow(allDepartures.filter(item => item.status !== 'cancelled'), now, settings.routeFilter);
|
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, now]);
|
||||||
|
const nightAvailable = Boolean(fullAvailable && [-1, 0, 1].every(days => timetable.data?.calendarYears.includes(Number(shiftDate(date, days).slice(0, 4)))));
|
||||||
|
const night = nightWindow(settings, displayDepartures(nightDepartures, alerts, settings.stopId, now, reliableAlerts), now, nightAvailable);
|
||||||
const reading = readability(settings);
|
const reading = readability(settings);
|
||||||
const held = shouldHoldScene(settings, first, now);
|
const held = shouldHoldScene(settings, first, now);
|
||||||
const urgent = alerts.some(alert => alert.type === 'cancellation' || alert.type === 'detour');
|
const urgent = alerts.some(alert => alert.type === 'cancellation' || alert.type === 'detour');
|
||||||
const hasAlerts = alerts.length > 0;
|
const hasAlerts = alerts.length > 0;
|
||||||
const scenes = useMemo(() => boardScenes(settings, hasAlerts, urgent), [settings, hasAlerts, urgent]);
|
const scenes = useMemo(() => night.active ? ['departures'] as Scene[] : boardScenes(settings, hasAlerts, urgent), [settings, hasAlerts, urgent, night.active]);
|
||||||
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 seconds = scene === 'departures' ? settings.mainSeconds : scene === 'info' ? Math.max(settings.infoSeconds, !hasAlerts && settings.infoServiceEnabled ? journeyPages * (reading.calm ? 6 : 4) : 0, Math.min(60, alerts.reduce((sum, alert) => sum + alert.message.length, 0) / 18)) : settings.adSeconds;
|
||||||
const elapsed = Date.now() - startedAt;
|
const elapsed = Date.now() - startedAt;
|
||||||
const progress = Math.min(1, elapsed / (seconds * 1000));
|
const progress = Math.min(1, elapsed / (seconds * 1000));
|
||||||
const sceneState = useRef({ scenes, scene, held, settingsOpen, paused, calm: reading.calm });
|
const sceneState = useRef({ scenes, scene, held, settingsOpen, paused, calm: reading.calm || night.active });
|
||||||
sceneState.current = { scenes, scene, held, settingsOpen, paused, calm: reading.calm };
|
sceneState.current = { scenes, scene, held, settingsOpen, paused, calm: reading.calm || night.active };
|
||||||
const showNotice = useCallback((text: string) => {
|
const showNotice = useCallback((text: string) => {
|
||||||
setNotice(text); if (noticeTimer.current) clearTimeout(noticeTimer.current);
|
setNotice(text); if (noticeTimer.current) clearTimeout(noticeTimer.current);
|
||||||
noticeTimer.current = setTimeout(() => setNotice(''), 4000);
|
noticeTimer.current = setTimeout(() => setNotice(''), 4000);
|
||||||
@@ -237,7 +246,7 @@ function BoardApplication({ management }: { management?: { state: ManagedState;
|
|||||||
if (held) { setScene('departures'); setStartedAt(Date.now()); }
|
if (held) { setScene('departures'); setStartedAt(Date.now()); }
|
||||||
else setStartedAt(Date.now());
|
else setStartedAt(Date.now());
|
||||||
}, [held]);
|
}, [held]);
|
||||||
useEffect(() => { if (!scenes.includes(scene)) { setScene(urgent ? 'info' : 'departures'); setStartedAt(Date.now()); } }, [scenes, urgent, scene]);
|
useEffect(() => { if (!scenes.includes(scene)) { setScene(urgent && scenes.includes('info') ? 'info' : 'departures'); setStartedAt(Date.now()); } }, [scenes, urgent, scene]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (paused || settingsOpen) { setStartedAt(Date.now()); return; }
|
if (paused || settingsOpen) { setStartedAt(Date.now()); return; }
|
||||||
if (scenes.length > 1 && !held && elapsed >= seconds * 1000) nextScene();
|
if (scenes.length > 1 && !held && elapsed >= seconds * 1000) nextScene();
|
||||||
@@ -259,22 +268,24 @@ function BoardApplication({ management }: { management?: { state: ManagedState;
|
|||||||
const timeParts = clockFormat.format(now).split(':');
|
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 updateLabel = fullAvailable && timetable.data ? timeFormat.format(Date.parse(timetable.data.updatedAt)) : data ? timeFormat.format(Date.parse(data.updatedAt)) : '—';
|
||||||
const direction = stopDirection(data?.stop);
|
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} data-profile={settings.displayProfile} data-distance={settings.viewingDistance} data-motion={reading.calm ? 'calm' : 'full'} style={{ '--reading-scale': reading.scale, '--accent': colorScheme(settings.theme === 'light' ? settings.lightColorScheme : settings.colorScheme, settings.theme).color } as CSSProperties}>
|
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>
|
<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="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>
|
</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 && <div className="offline-banner" role="status"><WifiOff /><span>{data ? `Нет свежих данных. Сохранённое расписание · обновлено в ${updateLabel}` : loading ? 'Подключаемся к RideBus…' : 'Не удалось загрузить расписание. Проверьте подключение.'}</span><button onClick={refresh} aria-label="Повторить подключение"><Refresh /></button></div>}
|
||||||
{management?.failed && <div className="managed-offline" role="status">Нет связи с управлением · сохранённые настройки</div>}
|
{management?.failed && <div className="managed-offline" role="status">Нет связи с управлением · сохранённые настройки</div>}
|
||||||
<main className="scene-container"><div className="scene" key={scene} data-scene={scene}>
|
<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} settings={settings} />}
|
{night.active && night.first && <NightScreen first={night.first} completedToday={night.completedToday} now={now} routeFiltered={Boolean(settings.routeFilter)} alerts={alerts} 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} settings={settings}
|
{!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} />}
|
||||||
service={{ ...service, available: fullAvailable, tomorrowAvailable: Boolean(fullAvailable && timetable.data?.calendarYears.includes(Number(shiftDate(date, 1).slice(0, 4)))), loading: timetable.loading, now }} />}
|
{!night.active && scene === 'info' && <InfoScreen data={data} alerts={alerts} fresh={fresh} seconds={seconds} paused={paused || settingsOpen} page={infoPage} onPage={setInfoPage} config={config} weather={weather} now={now} settings={settings} onJourneyPages={setJourneyPages}
|
||||||
{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>}
|
journey={{ departure: first, journey, loading: timetable.loading || loading, now, 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>
|
</div></main>
|
||||||
{scene !== 'departures' && <div className="persistent-next"><span className="eyebrow">БЛИЖАЙШИЙ РЕЙС</span>{first ? <><RouteBadge number={first.routeNumber} filled /><div className="persistent-destination"><strong>{first.destination}</strong><ScheduledTime departure={first} now={now} /></div><Countdown departure={first} now={now} /></> : <span>Нет доступных рейсов</span>}</div>}
|
{!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} /></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>
|
<footer className="board-footer"><div className="cycle-progress"><span style={{ transform: `scaleX(${held || paused || settingsOpen || scenes.length < 2 ? 0 : progress})` }} /></div>
|
||||||
<div className="footer-main"><nav className="scene-navigation" aria-label="Экраны табло">{scenes.map((item, index) => <button key={item} onClick={() => goTo(item)} className={scene === item ? 'active' : ''} aria-label={`0${index + 1} ${sceneNames[item]}`} title={sceneNames[item]} aria-current={scene === item ? 'page' : undefined}><span className="scene-index">0{index + 1}</span><span>{sceneNames[item]}</span></button>)}</nav>
|
<div className="footer-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 && <WifiOff />}<span>{settings.demo ? 'Демонстрация' : held ? 'Приоритет транспорта' : `Обновлено в ${updateLabel}`}{data?.partial ? ' · часть рейсов недоступна' : ''}{offlineSaved === false ? ' · сохранение недоступно' : ''}</span></div>
|
<div className="footer-status">{!fresh && !settings.demo && <WifiOff />}<span>{settings.demo ? 'Демонстрация' : held ? 'Приоритет транспорта' : `Обновлено в ${updateLabel}`}{data?.partial ? ' · часть рейсов недоступна' : ''}{offlineSaved === false ? ' · сохранение недоступно' : ''}</span></div>
|
||||||
<div className="board-controls"><button className="icon-button" onClick={() => { setPaused(value => !value); setStartedAt(Date.now()); }} aria-label={paused ? 'Продолжить смену экранов' : 'Приостановить смену экранов'} title="Пауза · пробел">{paused ? <Play /> : <Pause />}</button><button className="icon-button" onClick={nextScene} aria-label="Следующий экран" title="Следующий экран · →"><ArrowRight /></button><button className="icon-button" onClick={() => void toggleFullscreen()} aria-label={fullscreen ? 'Выйти из полного экрана' : 'На весь экран'} title="Полный экран · F"><Fullscreen /></button><button className="icon-button" onClick={() => setSettingsOpen(true)} aria-label="Настройки табло" title="Настройки · S"><SettingsIcon /></button></div></div>
|
<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>
|
</footer>
|
||||||
|
|||||||
50
src/NextStops.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { NumericText } from './NumericText';
|
||||||
|
import { RouteBadge } from './RouteBadge';
|
||||||
|
import { FittedDestination } from './FittedDestination';
|
||||||
|
import { localDate } from './lib/timetable.mjs';
|
||||||
|
import { durationParts } from './lib/presentation.mjs';
|
||||||
|
import { readability } from './lib/departures';
|
||||||
|
import type { OnwardJourney } from './lib/journey';
|
||||||
|
import type { Departure, Settings } from './types';
|
||||||
|
import type { CSSProperties } from 'react';
|
||||||
|
|
||||||
|
export function NextStops({ departure, journey, loading, now, paused, settings, active, onPages }: {
|
||||||
|
departure?: Departure; journey: OnwardJourney | null; loading: boolean; now: number; paused: boolean; settings: Settings;
|
||||||
|
active: boolean; onPages: (pages: number) => void;
|
||||||
|
}) {
|
||||||
|
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 (active) onPages(pages); }, [active, pages, onPages]);
|
||||||
|
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]);
|
||||||
|
const tomorrow = departure && localDate(Date.parse(departure.scheduledAt)) > localDate(now);
|
||||||
|
return <><h3>Дальше по маршруту</h3>
|
||||||
|
{!departure ? <p className="journey-empty">{loading ? 'Получаем расписание' : 'Нет доступного рейса'}</p> : <>
|
||||||
|
<div className="journey-route"><RouteBadge number={departure.routeNumber} filled /><div className="journey-trip"><div className="journey-route-name"><FittedDestination text={departure.destination} /></div><time dateTime={departure.scheduledAt}><NumericText value={departure.scheduledTime} />{tomorrow && <span className="journey-day">завтра</span>}</time></div></div>
|
||||||
|
{!journey ? <p className="journey-empty">{loading ? 'Загружаем остановки' : 'Схема маршрута пока недоступна'}</p> : !count ? <p className="journey-empty">Здесь конечная этого рейса</p> : <>
|
||||||
|
<div className="journey-metro" data-calm={reading.calm} data-more={journey.more > 0} 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"><div className="journey-rail-window"><ol className="journey-rail" aria-label="Следующие остановки после посадки" style={{ '--journey-focus': focus } as CSSProperties}>
|
||||||
|
{journey.stops.map((stop, index) => <li className="journey-station" key={stop.id} data-focused={index === focus} data-before={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><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"><FittedDestination key={index === focus ? 'focus' : 'context'} text={stop.name} /></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><span className="journey-direction-arrow" aria-hidden="true">›</span></div></div>
|
||||||
|
</div>
|
||||||
|
<div className="journey-caption"><span>Время пути по расписанию</span>{pages > 1 && <span className="journey-page" aria-label={`${focus + 1} из ${count} остановок в обзоре`}>{focus + 1} / {count}</span>}</div>
|
||||||
|
</>}
|
||||||
|
</>}
|
||||||
|
</>;
|
||||||
|
}
|
||||||
38
src/NightScreen.tsx
Normal 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>;
|
||||||
|
}
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import { NumericText } from './NumericText';
|
|
||||||
import { RouteBadge } from './RouteBadge';
|
|
||||||
import type { Departure } from './types';
|
|
||||||
|
|
||||||
export function ServiceWindow({ lastToday, firstTomorrow, available, tomorrowAvailable, loading, now }: {
|
|
||||||
lastToday: Departure | null; firstTomorrow: Departure | null; available: boolean;
|
|
||||||
tomorrowAvailable: boolean; loading: boolean; now: number;
|
|
||||||
}) {
|
|
||||||
return <><h3>Первый и последний рейс</h3>
|
|
||||||
{available ? <div className="service-window">
|
|
||||||
{[
|
|
||||||
{ label: 'Последний сегодня', departure: lastToday, empty: 'Сегодня рейсов нет', ended: Boolean(lastToday && Date.parse(lastToday.scheduledAt) + 60000 <= now) },
|
|
||||||
{ label: 'Первый завтра', departure: firstTomorrow, empty: tomorrowAvailable ? 'Завтра рейсов нет' : 'Расписание на завтра недоступно', ended: false },
|
|
||||||
].map(item => <div className="service-boundary" key={item.label}>
|
|
||||||
<div className="service-label">{item.label}{item.ended && <span>время прошло</span>}</div>
|
|
||||||
{item.departure ? <><time dateTime={item.departure.scheduledAt}><NumericText value={item.departure.scheduledTime} /></time>
|
|
||||||
<div className="service-trip"><RouteBadge number={item.departure.routeNumber} filled /><span>{item.departure.destination}</span></div>
|
|
||||||
</> : <p className="service-empty">{item.empty}</p>}
|
|
||||||
</div>)}
|
|
||||||
</div> : <p className="service-unavailable">{loading ? 'Загружаем полное расписание' : 'Полное расписание пока недоступно'}</p>}
|
|
||||||
</>;
|
|
||||||
}
|
|
||||||
@@ -96,6 +96,8 @@ export function SettingsPanel({ settings, config, data, onSave, onClose, onFulls
|
|||||||
<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>Когда до рейса осталось не больше 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>В том же направлении, под ближайшим отправлением</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>
|
<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>
|
<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>)}
|
{informationOptions.map(block => <label className="switch-row" key={block.id}><span>{block.label}</span><input type="checkbox" checked={draft[block.setting]} onChange={event => update(block.setting, event.target.checked)} /></label>)}
|
||||||
{!informationBlocks(draft).length && <p className="form-hint">Экран информации появится только при предупреждениях о движении.</p>}
|
{!informationBlocks(draft).length && <p className="form-hint">Экран информации появится только при предупреждениях о движении.</p>}
|
||||||
@@ -108,7 +110,7 @@ export function SettingsPanel({ settings, config, data, onSave, onClose, onFulls
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</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-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, showNextSameRoute: true, longWaitEnabled: true, infoServiceEnabled: true, infoWeatherEnabled: true, infoQrEnabled: true, adsEnabled: false }); }}><Reset size={17} /> Сбросить цикл</button><button type="submit" className="primary-button"><Check size={18} /> Сохранить</button></div>
|
<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>
|
</form>
|
||||||
</dialog>;
|
</dialog>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import type { BoardData } from '../types';
|
import type { BoardData } from '../types';
|
||||||
import { minskDate } from './board.mjs';
|
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 function createDemo(now = Date.now()): BoardData {
|
export function createDemo(now = Date.now()): BoardData {
|
||||||
const routes = [
|
const routes = [
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
import { colorSchemes, lightColorSchemes } from './presentation.mjs';
|
import { colorSchemes, lightColorSchemes } from './presentation.mjs';
|
||||||
export const MANAGED_KEY = 'ridebus-board-managed-v1';
|
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: { nightMode: 'auto', nightWakeMinutes: 60, ...value.device.settings } } };
|
||||||
|
}
|
||||||
export function isManagedState(value) {
|
export function isManagedState(value) {
|
||||||
|
value = hydrateManagedState(value);
|
||||||
const device = value?.device, s = device?.settings, config = value?.config;
|
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 (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;
|
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;
|
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 (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;
|
if (!colorSchemes.some(color => color.id === s.colorScheme) || !lightColorSchemes.some(color => color.id === s.lightColorScheme)) return false;
|
||||||
for (const key of ['holdNear', 'infoServiceEnabled', 'infoWeatherEnabled', 'infoQrEnabled', 'showNextSameRoute', 'longWaitEnabled', 'adsEnabled']) if (typeof s[key] !== 'boolean') return false;
|
for (const key of ['holdNear', 'infoServiceEnabled', 'infoWeatherEnabled', 'infoQrEnabled', 'showNextSameRoute', 'longWaitEnabled', 'adsEnabled']) if (typeof s[key] !== 'boolean') return false;
|
||||||
return typeof s.routeFilter === 'string' && /^(|[1-9]\d{0,8})$/.test(s.routeFilter) && typeof s.adTitle === 'string' && s.adTitle.length <= 80 && typeof s.adText === 'string' && s.adText.length <= 200 && typeof config.cityName === 'string' && typeof config.stopName === 'string';
|
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';
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import type { Departure, Scene, Settings } from '../types';
|
import type { Scene, Settings } from '../types';
|
||||||
import { localDate, shiftDate } from './timetable.mjs';
|
|
||||||
|
|
||||||
export const informationOptions = [
|
export const informationOptions = [
|
||||||
{ id: 'service', setting: 'infoServiceEnabled', name: 'Движение', label: 'Первый и последний рейс' },
|
{ id: 'service', setting: 'infoServiceEnabled', name: 'Маршрут', label: 'Дальше по маршруту' },
|
||||||
{ id: 'weather', setting: 'infoWeatherEnabled', name: 'Погода', label: 'Погода' },
|
{ id: 'weather', setting: 'infoWeatherEnabled', name: 'Погода', label: 'Погода' },
|
||||||
{ id: 'qr', setting: 'infoQrEnabled', name: 'RideBus', label: 'QR-код расписания' },
|
{ id: 'qr', setting: 'infoQrEnabled', name: 'RideBus', label: 'QR-код расписания' },
|
||||||
] as const;
|
] as const;
|
||||||
@@ -15,20 +14,3 @@ export function boardScenes(settings: Settings, hasAlerts: boolean, urgent: bool
|
|||||||
return ['departures', ...(hasAlerts || informationBlocks(settings).length ? ['info'] : []),
|
return ['departures', ...(hasAlerts || informationBlocks(settings).length ? ['info'] : []),
|
||||||
...(settings.adsEnabled && !urgent ? ['ad'] : [])] as Scene[];
|
...(settings.adsEnabled && !urgent ? ['ad'] : [])] as Scene[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use the complete timetable, including arrivals carried over from the previous
|
|
||||||
// service day. A short live arrivals response cannot establish the last trip.
|
|
||||||
export function serviceWindow(departures: Departure[], now: number, routeFilter: string) {
|
|
||||||
const today = localDate(now);
|
|
||||||
const todayStart = Date.parse(`${today}T00:00:00+03:00`);
|
|
||||||
const tomorrowStart = Date.parse(`${shiftDate(today, 1)}T00:00:00+03:00`);
|
|
||||||
const tomorrowEnd = tomorrowStart + 86400000;
|
|
||||||
let lastToday: Departure | null = null, firstTomorrow: Departure | null = null;
|
|
||||||
for (const departure of departures) {
|
|
||||||
if (routeFilter && String(departure.routeId) !== routeFilter) continue;
|
|
||||||
const at = Date.parse(departure.scheduledAt);
|
|
||||||
if (at >= todayStart && at < tomorrowStart && (!lastToday || at > Date.parse(lastToday.scheduledAt))) lastToday = departure;
|
|
||||||
if (at >= tomorrowStart && at < tomorrowEnd && (!firstTomorrow || at < Date.parse(firstTomorrow.scheduledAt))) firstTomorrow = departure;
|
|
||||||
}
|
|
||||||
return { lastToday, firstTomorrow };
|
|
||||||
}
|
|
||||||
|
|||||||
23
src/lib/journey.ts
Normal 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
@@ -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 };
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ export const defaultSettings: Settings = { stopId: 1, demo: false, mainSeconds:
|
|||||||
rowCount: 4, routeFilter: '', theme: 'dark', colorScheme: 'lime', lightColorScheme: 'blue',
|
rowCount: 4, routeFilter: '', theme: 'dark', colorScheme: 'lime', lightColorScheme: 'blue',
|
||||||
infoServiceEnabled: true, infoWeatherEnabled: true, infoQrEnabled: true,
|
infoServiceEnabled: true, infoWeatherEnabled: true, infoQrEnabled: true,
|
||||||
showNextSameRoute: true, longWaitEnabled: true, displayProfile: 'tv', viewingDistance: 'near',
|
showNextSameRoute: true, longWaitEnabled: true, displayProfile: 'tv', viewingDistance: 'near',
|
||||||
|
nightMode: 'auto', nightWakeMinutes: 60,
|
||||||
adsEnabled: false, adSeconds: 7, adTitle: 'Ваше место в городе', adText: 'Короткая история. Большая аудитория.' };
|
adsEnabled: false, adSeconds: 7, adTitle: 'Ваше место в городе', adText: 'Короткая история. Большая аудитория.' };
|
||||||
export function readSettings(): Settings {
|
export function readSettings(): Settings {
|
||||||
try {
|
try {
|
||||||
@@ -22,6 +23,7 @@ export function readSettings(): Settings {
|
|||||||
infoServiceEnabled: saved.infoServiceEnabled !== false, infoWeatherEnabled: saved.infoWeatherEnabled !== false, infoQrEnabled: saved.infoQrEnabled !== false,
|
infoServiceEnabled: saved.infoServiceEnabled !== false, infoWeatherEnabled: saved.infoWeatherEnabled !== false, infoQrEnabled: saved.infoQrEnabled !== false,
|
||||||
showNextSameRoute: saved.showNextSameRoute !== false, longWaitEnabled: saved.longWaitEnabled !== false,
|
showNextSameRoute: saved.showNextSameRoute !== false, longWaitEnabled: saved.longWaitEnabled !== false,
|
||||||
displayProfile: saved.displayProfile === 'street' ? 'street' : 'tv', viewingDistance: ['medium', 'far'].includes(saved.viewingDistance) ? saved.viewingDistance : 'near',
|
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),
|
adsEnabled: saved.adsEnabled === true, adSeconds: bound(saved.adSeconds, 7, 5, 30),
|
||||||
adTitle: typeof saved.adTitle === 'string' ? saved.adTitle.slice(0, 80) : defaultSettings.adTitle,
|
adTitle: typeof saved.adTitle === 'string' ? saved.adTitle.slice(0, 80) : defaultSettings.adTitle,
|
||||||
adText: typeof saved.adText === 'string' ? saved.adText.slice(0, 200) : defaultSettings.adText };
|
adText: typeof saved.adText === 'string' ? saved.adText.slice(0, 200) : defaultSettings.adText };
|
||||||
|
|||||||
@@ -12,12 +12,17 @@ export function isTimetable(value, cityId, stopId) {
|
|||||||
&& Array.isArray(value.calendarYears) && value.calendarYears.every(Number.isInteger)
|
&& Array.isArray(value.calendarYears) && value.calendarYears.every(Number.isInteger)
|
||||||
&& Array.isArray(value.holidays) && value.holidays.every(validDate)
|
&& 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'
|
&& 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 =>
|
&& typeof route.name === 'string' && Array.isArray(route.schedules) && route.schedules.every(schedule =>
|
||||||
['weekday', 'saturday', 'sunday', 'holiday'].includes(schedule.dayType) && (!schedule.validFrom || validDate(schedule.validFrom))
|
['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 =>
|
&& (!schedule.validUntil || validDate(schedule.validUntil)) && Array.isArray(schedule.trips) && schedule.trips.every(trip =>
|
||||||
Number.isFinite(trip.arrivalSeconds) && trip.arrivalSeconds >= 0 && trip.arrivalSeconds < 172800
|
Number.isFinite(trip.arrivalSeconds) && trip.arrivalSeconds >= 0 && trip.arrivalSeconds < 172800
|
||||||
&& typeof trip.destination === 'string' && typeof trip.note === 'string' && Array.isArray(trip.daysOfWeek)
|
&& typeof trip.destination === 'string' && typeof trip.note === 'string' && Array.isArray(trip.daysOfWeek)
|
||||||
&& (trip.originStopId == null || Number.isSafeInteger(trip.originStopId) && trip.originStopId > 0)
|
&& (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))
|
&& (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)))));
|
&& Array.isArray(trip.exceptDaysOfWeek) && [...trip.daysOfWeek, ...trip.exceptDaysOfWeek].every(day => Number.isInteger(day) && day >= 1 && day <= 7)))));
|
||||||
}
|
}
|
||||||
@@ -45,7 +50,7 @@ export function timetableDepartures(bundle, date) {
|
|||||||
if (arrivalDate < date || arrivalDate > shiftDate(date, 1)) continue;
|
if (arrivalDate < date || arrivalDate > shiftDate(date, 1)) continue;
|
||||||
result.push({ id: `${route.id}:${serviceDate}:${index}:${scheduledAt}`, routeId: route.id, routeNumber: route.number,
|
result.push({ id: `${route.id}:${serviceDate}:${index}:${scheduledAt}`, routeId: route.id, routeNumber: route.number,
|
||||||
routeName: route.name, destination: trip.destination, type: route.type, scheduledAt,
|
routeName: route.name, destination: trip.destination, type: route.type, scheduledAt,
|
||||||
serviceDate, originStopId: trip.originStopId, originTime: trip.originTime,
|
serviceDate, originStopId: trip.originStopId, originTime: trip.originTime, endStopId: trip.endStopId,
|
||||||
scheduledTime: new Date(Date.parse(scheduledAt) + 10800000).toISOString().slice(11, 16), note: trip.note, source: 'schedule' });
|
scheduledTime: new Date(Date.parse(scheduledAt) + 10800000).toISOString().slice(11, 16), note: trip.note, source: 'schedule' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
102
src/styles.css
@@ -101,33 +101,82 @@ h1 { font-size: clamp(30px, 3.8vw, 76px); letter-spacing: -.05em; line-height: 1
|
|||||||
.route-card-name { flex: 1 1 0; min-width: 0; height: 100%; display: flex; flex-direction: column; justify-content: center; }
|
.route-card-name { flex: 1 1 0; min-width: 0; height: 100%; display: flex; flex-direction: column; justify-content: center; }
|
||||||
.route-list .destination-heading { width: 100%; font-size: clamp(22px, 3cqw, 30px); line-height: 1.2; margin: 0; font-weight: 550; letter-spacing: -.025em; overflow-wrap: anywhere; }
|
.route-list .destination-heading { width: 100%; font-size: clamp(22px, 3cqw, 30px); line-height: 1.2; margin: 0; font-weight: 550; letter-spacing: -.025em; overflow-wrap: anywhere; }
|
||||||
.route-list-pages { display: flex; justify-content: flex-end; color: var(--muted); font-size: 16px; padding-top: 10px; }
|
.route-list-pages { display: flex; justify-content: flex-end; color: var(--muted); font-size: 16px; padding-top: 10px; }
|
||||||
.service-info { container-name: service-card; }
|
/* A moving line and one large stop, inspired by over-door transit displays. */
|
||||||
.service-window { flex: 1 1 0; min-height: 0; display: grid; grid-template-rows: repeat(2, minmax(0, 1fr)); gap: 20px; }
|
.service-info { container-name: journey-card; overflow: hidden; }
|
||||||
.service-boundary { min-width: 0; min-height: 0; display: flex; flex-direction: column; justify-content: center; gap: 12px; }
|
.journey-route { display: flex; align-items: center; gap: 18px; min-width: 0; padding-bottom: 18px; border-bottom: 1px solid var(--line); }
|
||||||
.service-boundary + .service-boundary { border-top: 1px solid var(--line); padding-top: 20px; }
|
.journey-route .route-badge { flex-shrink: 0; width: 80px; height: 62px; border-radius: 18px; font-size: 36px; }
|
||||||
.service-label { display: flex; flex-wrap: wrap; gap: 4px 10px; color: var(--secondary); font-size: clamp(17px, 3cqw, 24px); line-height: 1.25; }
|
.journey-trip { min-width: 0; flex: 1; display: flex; align-items: center; gap: 18px; }
|
||||||
.service-label span { color: var(--muted); font-size: .8em; align-self: center; }
|
.journey-route-name { min-width: 0; flex: 1; }
|
||||||
.service-boundary > time { display: block; color: var(--accent); font-size: clamp(44px, min(17cqw, 19cqh), 112px); font-weight: 600; line-height: 1; }
|
.service-info .journey-trip .destination-heading { margin: 0; font-size: clamp(22px, 3.8cqw, 36px); line-height: 1.2; overflow-wrap: anywhere; }
|
||||||
.service-trip { min-width: 0; display: flex; align-items: center; gap: 12px; font-size: clamp(18px, 4cqw, 26px); line-height: 1.2; }
|
.journey-trip time { display: flex; gap: 8px; align-items: baseline; color: var(--accent); font-size: clamp(22px, 4.5cqw, 40px); font-weight: 600; }
|
||||||
.service-trip .route-badge { flex-shrink: 0; width: 72px; height: 48px; border-radius: 14px; font-size: 32px; padding: 0 8px; }
|
.journey-trip .journey-day { color: var(--secondary); font-size: 17px; font-weight: 400; }
|
||||||
.service-trip > span:last-child { overflow-wrap: anywhere; }
|
.service-info .journey-empty { margin-block: auto; padding: 20px 0; text-align: center; font-size: 22px; }
|
||||||
.service-info .service-empty { margin: 0; font-size: 20px; }
|
.journey-metro { --station-width: clamp(180px, 22cqw, 260px); --focused-width: min(clamp(300px, 60cqw, 560px), calc(100cqw - 48px)); flex: 1 1 0; min-height: 0; min-width: 0; display: flex; flex-direction: column; padding-top: clamp(16px, 4cqh, 32px); }
|
||||||
.service-info .service-unavailable { margin-block: auto; text-align: center; font-size: 22px; }
|
.journey-tour-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; color: var(--secondary); font-size: clamp(17px, 3.3cqw, 24px); line-height: 1.3; }
|
||||||
@container service-card (min-width: 600px) {
|
.journey-order { color: var(--accent); font-size: .85em; white-space: nowrap; }
|
||||||
.service-window { grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-rows: minmax(0, 1fr); gap: 32px; }
|
.journey-map-space { flex: 1 1 0; min-height: 0; display: flex; align-items: center; margin: 18px -8px 0; }
|
||||||
.service-boundary + .service-boundary { border-top: 0; border-left: 1px solid var(--line); padding-top: 0; padding-left: 32px; }
|
.journey-rail-window { width: 100%; height: min(100%, 320px); position: relative; overflow: hidden; }
|
||||||
|
.journey-rail-window::after { content: ''; position: absolute; inset: 0 0 0 auto; width: 36px; background: linear-gradient(90deg, transparent, var(--background)); pointer-events: none; }
|
||||||
|
.journey-rail { display: flex; height: 100%; margin: 0; padding: 0 0 0 24px; list-style: none; transform: translateX(calc(-1 * var(--journey-focus) * var(--station-width))); transition: transform 900ms cubic-bezier(.22,.72,.18,1); }
|
||||||
|
.journey-station { flex: 0 0 var(--station-width); min-width: 0; position: relative; display: grid; grid-template-rows: 34px minmax(0, 1fr) 36px; gap: 10px; padding-right: 28px; transition: flex-basis 900ms cubic-bezier(.22,.72,.18,1); }
|
||||||
|
.journey-station[data-focused=true] { flex-basis: var(--focused-width); }
|
||||||
|
.journey-station::after { content: ''; position: absolute; bottom: 15.5px; left: 0; width: 100%; height: 5px; background: var(--accent); transition: opacity 700ms ease; }
|
||||||
|
.journey-station:last-child::after { width: calc(var(--station-width) - 36px); }
|
||||||
|
.journey-metro[data-more=false] .journey-station:last-child::after { display: none; }
|
||||||
|
.journey-station[data-before=true]::after { opacity: .25; }
|
||||||
|
.journey-station-name { min-width: 0; min-height: 0; display: flex; flex-direction: column; justify-content: flex-end; padding-bottom: 8px; transition: opacity 500ms ease; }
|
||||||
|
.journey-station[data-before=true] .journey-station-name, .journey-station[data-before=true] .journey-duration { opacity: 0; }
|
||||||
|
.service-info .journey-station-name .destination-heading { flex-shrink: 0; margin: 0; font-size: calc(clamp(22px, 4cqw, 32px) * var(--reading-scale, 1)); line-height: 1.13; font-weight: 600; letter-spacing: -.035em; overflow-wrap: anywhere; transition: color 600ms ease; }
|
||||||
|
.service-info .journey-station[data-focused=true] .destination-heading { font-size: calc(clamp(48px, 11cqw, 96px) * var(--reading-scale, 1)); font-weight: 650; letter-spacing: -.055em; color: var(--foreground); }
|
||||||
|
.journey-station[data-focused=true] .destination-current { transform-origin: left bottom; animation: journey-name-in 900ms cubic-bezier(.22,.72,.18,1) both; }
|
||||||
|
@keyframes journey-name-in { from { opacity: 0; transform: translateX(20px) scale(.84); } to { opacity: 1; transform: translateX(0) scale(1); } }
|
||||||
|
.journey-duration { align-self: start; display: flex; align-items: baseline; gap: 8px; color: var(--muted); font-size: clamp(18px, 3.4cqw, 26px); font-weight: 550; white-space: nowrap; transition: color 600ms ease, opacity 500ms ease; }
|
||||||
|
.journey-duration > span { display: inline-flex; align-items: baseline; gap: 5px; }
|
||||||
|
.journey-duration small { font-size: .72em; font-weight: 500; }
|
||||||
|
.journey-station[data-focused=true] .journey-duration { color: var(--accent); font-size: clamp(24px, 4.5cqw, 36px); }
|
||||||
|
.journey-node { align-self: center; display: block; width: 18px; height: 18px; border: 4px solid var(--accent); border-radius: 50%; background: var(--background); margin-left: -9px; z-index: 1; transition: background 600ms ease, box-shadow 600ms ease; }
|
||||||
|
.journey-station[data-focused=true] .journey-node { background: var(--accent); box-shadow: 0 0 0 6px color-mix(in srgb, var(--accent) 18%, transparent); }
|
||||||
|
.journey-station[data-before=true] .journey-node { border-color: var(--outline); }
|
||||||
|
.journey-continuation { flex: 0 0 var(--station-width); min-width: 0; display: flex; align-items: center; position: relative; padding: 34px 20px 36px 0; }
|
||||||
|
.journey-continuation-label { color: var(--muted); font-size: clamp(20px, 4cqw, 28px); line-height: 1.2; max-width: 180px; }
|
||||||
|
.journey-continuation-line { position: absolute; bottom: 15.5px; left: 0; right: 24px; border-top: 5px dashed var(--accent); opacity: .5; }
|
||||||
|
.journey-direction-arrow { position: absolute; left: 1px; bottom: 1px; color: var(--accent); font-size: 42px; line-height: 36px; font-weight: 700; }
|
||||||
|
.journey-caption { display: flex; flex-wrap: wrap; gap: 4px 14px; justify-content: space-between; color: var(--muted); font-size: 16px; line-height: 1.3; padding-top: 16px; }
|
||||||
|
.journey-page { color: var(--accent); margin-left: auto; }
|
||||||
|
.journey-metro[data-calm=true] .journey-rail { transition: transform 300ms ease-out; }
|
||||||
|
.journey-metro[data-calm=true] .journey-station { transition: flex-basis 300ms ease-out; }
|
||||||
|
.journey-metro[data-calm=true] .destination-heading { transition: color 250ms ease-out; }
|
||||||
|
.journey-metro[data-calm=true] .destination-current { animation: none; }
|
||||||
|
.journey-metro[data-loop=true] .journey-rail { transition: none; animation: journey-overview-reset 600ms ease-out both; }
|
||||||
|
.journey-metro[data-loop=true] .journey-station { transition: none; }
|
||||||
|
@keyframes journey-overview-reset { from { opacity: 0; } to { opacity: 1; } }
|
||||||
|
@container journey-card (max-width: 380px) {
|
||||||
|
.journey-trip { display: block; }
|
||||||
|
.journey-trip time { margin-top: 7px; }
|
||||||
|
.journey-metro { --station-width: 140px; }
|
||||||
|
.service-info .journey-station[data-focused=true] .destination-heading { font-size: calc(clamp(34px, 11cqw, 46px) * var(--reading-scale, 1)); }
|
||||||
|
.journey-station { padding-right: 22px; }
|
||||||
|
.journey-tour-heading { font-size: 17px; }
|
||||||
|
.journey-caption { font-size: 14px; gap: 4px 10px; }
|
||||||
}
|
}
|
||||||
@container service-card (max-height: 380px) {
|
@container journey-card (max-height: 380px) {
|
||||||
.service-window { gap: 12px; }
|
.journey-route { gap: 12px; padding-bottom: 12px; }
|
||||||
.service-boundary { gap: 8px; }
|
.journey-route .route-badge { width: 72px; height: 48px; border-radius: 14px; font-size: 30px; }
|
||||||
.service-boundary + .service-boundary { padding-top: 12px; }
|
.service-info .journey-trip .destination-heading { font-size: 24px; }
|
||||||
.service-label { font-size: 17px; }
|
.journey-trip time { font-size: 24px; }
|
||||||
.service-boundary > time { font-size: clamp(40px, min(14cqw, 25cqh), 72px); }
|
.journey-metro { padding-top: 12px; }
|
||||||
.service-trip { font-size: 18px; }
|
.journey-map-space { margin-top: 10px; }
|
||||||
.service-trip .route-badge { width: 60px; height: 40px; font-size: 28px; border-radius: 12px; }
|
.journey-station { grid-template-rows: 26px minmax(0, 1fr) 28px; gap: 6px; }
|
||||||
|
.journey-station-name { padding-bottom: 4px; }
|
||||||
|
.journey-station::after { bottom: 12px; height: 4px; }
|
||||||
|
.journey-direction-arrow { bottom: 0; font-size: 36px; line-height: 28px; }
|
||||||
|
.journey-node { width: 16px; height: 16px; margin-left: -8px; border-width: 3px; }
|
||||||
|
.journey-continuation-line { bottom: 12px; border-top-width: 4px; }
|
||||||
|
.journey-caption { font-size: 14px; padding-top: 10px; }
|
||||||
}
|
}
|
||||||
@container service-card (min-width: 600px) and (max-height: 380px) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.service-boundary + .service-boundary { padding-top: 0; padding-left: 20px; }
|
.journey-rail, .service-info .journey-station .destination-heading, .journey-station, .journey-station-name, .journey-duration, .journey-station::after, .journey-node { transition: none; }
|
||||||
|
.journey-metro .destination-current, .journey-metro[data-loop=true] .journey-rail { animation: none; }
|
||||||
}
|
}
|
||||||
.weather-info { container-name: weather-card; }
|
.weather-info { container-name: weather-card; }
|
||||||
.weather-body { flex: 1 1 0; min-height: 0; display: grid; grid-template-columns: minmax(0, 1fr); align-content: center; align-items: center; justify-items: center; gap: 24px; }
|
.weather-body { flex: 1 1 0; min-height: 0; display: grid; grid-template-columns: minmax(0, 1fr); align-content: center; align-items: center; justify-items: center; gap: 24px; }
|
||||||
@@ -518,3 +567,6 @@ h1 { font-size: clamp(30px, 3.8vw, 76px); letter-spacing: -.05em; line-height: 1
|
|||||||
|
|
||||||
/* Device onboarding keeps the same black/lime typography as the board. */
|
/* Device onboarding keeps the same black/lime typography as the board. */
|
||||||
.device-setup{min-height:100dvh;background:#000;color:#fff;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:clamp(24px,4vw,64px);text-align:center;gap:clamp(22px,4vh,48px);--accent:#d5ff70}.device-setup-brand{display:flex;align-items:center;gap:12px;font-size:32px;font-weight:800;letter-spacing:-.07em}.device-setup-brand>span{display:block;width:48px;color:var(--accent)}.device-setup-brand svg{width:100%;height:auto}.device-setup-card{width:min(850px,100%);border:1px solid #38402a;border-radius:32px;padding:clamp(22px,4vw,52px);background:radial-gradient(ellipse at top,#d5ff7011,transparent 65%)}.device-setup-card h1{font-size:clamp(30px,4.4vw,62px);margin:12px 0 18px;letter-spacing:-.055em}.device-setup-card p{font-size:clamp(18px,2vw,26px);color:#b3b9aa;max-width:640px;margin:0 auto;line-height:1.4}.device-pair-code{display:flex;justify-content:center;font-weight:750;font-size:clamp(64px,min(14vw,18vh),150px);letter-spacing:.08em;margin:22px 0;color:var(--accent)}.device-pair-expiry{display:flex;justify-content:center;align-items:center;gap:10px;font-size:clamp(18px,2vw,24px);color:#b3b9aa}.device-pair-expiry .numeric-text{color:var(--accent)}.device-setup button,.managed-panel button{display:inline-flex;align-items:center;justify-content:center;gap:10px;padding:14px 20px;font-size:18px;border:1px solid #555;border-radius:14px;color:#fff;background:#161616;min-height:50px}.device-setup-card button{margin-top:24px}.device-setup-card a{display:block;margin-top:20px;color:var(--accent);font-size:20px}.device-setup-status{color:#ffd479;margin-top:20px;font-size:18px}.managed-panel{color:var(--text);background:var(--background,#000);border:1px solid var(--border,#555);border-radius:26px;padding:32px;width:min(600px,calc(100% - 32px));max-height:calc(100dvh - 32px);overflow:auto}.managed-panel::backdrop{background:#000a;backdrop-filter:blur(8px)}.managed-panel>svg{color:var(--accent)}.managed-panel h2{font-size:28px;margin:16px 0}.managed-panel strong{font-size:22px;color:var(--accent)}.managed-panel p{font-size:18px;line-height:1.5;margin:16px 0;color:var(--muted,#aaa)}.managed-panel dl{display:grid;grid-template-columns:1fr 1fr;gap:12px;font-size:16px;margin:20px 0}.managed-panel>div{display:flex;gap:12px;flex-wrap:wrap}.managed-offline{color:var(--warning);font-size:14px;position:fixed;top:8px;left:50%;transform:translateX(-50%);background:var(--background,#000);padding:6px 12px;border-radius:10px;z-index:3;max-width:90vw;text-align:center}@media(max-height:650px){.device-setup{gap:16px;padding:20px}.device-setup-card{padding:24px}.device-pair-code{margin:14px 0}.device-setup-brand{font-size:26px}.device-setup-brand>span{width:36px}}
|
.device-setup{min-height:100dvh;background:#000;color:#fff;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:clamp(24px,4vw,64px);text-align:center;gap:clamp(22px,4vh,48px);--accent:#d5ff70}.device-setup-brand{display:flex;align-items:center;gap:12px;font-size:32px;font-weight:800;letter-spacing:-.07em}.device-setup-brand>span{display:block;width:48px;color:var(--accent)}.device-setup-brand svg{width:100%;height:auto}.device-setup-card{width:min(850px,100%);border:1px solid #38402a;border-radius:32px;padding:clamp(22px,4vw,52px);background:radial-gradient(ellipse at top,#d5ff7011,transparent 65%)}.device-setup-card h1{font-size:clamp(30px,4.4vw,62px);margin:12px 0 18px;letter-spacing:-.055em}.device-setup-card p{font-size:clamp(18px,2vw,26px);color:#b3b9aa;max-width:640px;margin:0 auto;line-height:1.4}.device-pair-code{display:flex;justify-content:center;font-weight:750;font-size:clamp(64px,min(14vw,18vh),150px);letter-spacing:.08em;margin:22px 0;color:var(--accent)}.device-pair-expiry{display:flex;justify-content:center;align-items:center;gap:10px;font-size:clamp(18px,2vw,24px);color:#b3b9aa}.device-pair-expiry .numeric-text{color:var(--accent)}.device-setup button,.managed-panel button{display:inline-flex;align-items:center;justify-content:center;gap:10px;padding:14px 20px;font-size:18px;border:1px solid #555;border-radius:14px;color:#fff;background:#161616;min-height:50px}.device-setup-card button{margin-top:24px}.device-setup-card a{display:block;margin-top:20px;color:var(--accent);font-size:20px}.device-setup-status{color:#ffd479;margin-top:20px;font-size:18px}.managed-panel{color:var(--text);background:var(--background,#000);border:1px solid var(--border,#555);border-radius:26px;padding:32px;width:min(600px,calc(100% - 32px));max-height:calc(100dvh - 32px);overflow:auto}.managed-panel::backdrop{background:#000a;backdrop-filter:blur(8px)}.managed-panel>svg{color:var(--accent)}.managed-panel h2{font-size:28px;margin:16px 0}.managed-panel strong{font-size:22px;color:var(--accent)}.managed-panel p{font-size:18px;line-height:1.5;margin:16px 0;color:var(--muted,#aaa)}.managed-panel dl{display:grid;grid-template-columns:1fr 1fr;gap:12px;font-size:16px;margin:20px 0}.managed-panel>div{display:flex;gap:12px;flex-wrap:wrap}.managed-offline{color:var(--warning);font-size:14px;position:fixed;top:8px;left:50%;transform:translateX(-50%);background:var(--background,#000);padding:6px 12px;border-radius:10px;z-index:3;max-width:90vw;text-align:center}@media(max-height:650px){.device-setup{gap:16px;padding:20px}.device-setup-card{padding:24px}.device-pair-code{margin:14px 0}.device-setup-brand{font-size:26px}.device-setup-brand>span{width:36px}}
|
||||||
|
|
||||||
|
/* Quiet schedule-based night screen. The saved daytime theme remains intact. */
|
||||||
|
.board[data-night=true] .cycle-progress{opacity:0}.night-screen{height:100%;min-height:0;display:flex;flex-direction:column;gap:clamp(14px,2.4vh,30px);padding:clamp(14px,2.2vh,28px) 0;container-type:size}.night-intro{flex-shrink:0}.night-intro .eyebrow{display:flex;align-items:center;gap:10px;color:var(--accent)}.night-intro h2{font-size:clamp(26px,3.3vw,54px);letter-spacing:-.05em;font-weight:650;margin:12px 0 0;line-height:1.15;max-width:1000px}.night-content{flex:1;min-height:0;display:grid;grid-template-columns:minmax(0,1fr);align-items:center}.night-service{min-width:0;display:flex;flex-direction:column;gap:clamp(10px,2cqh,20px)}.night-service-label{font-size:clamp(20px,2vw,32px);color:var(--muted)}.night-service-time{font-size:clamp(64px,min(13vw,28cqh),180px);font-weight:650;line-height:1;color:var(--accent);letter-spacing:-.055em;display:block}.night-route{display:flex;align-items:center;gap:clamp(14px,2vw,26px);min-width:0}.night-route .route-badge{width:clamp(72px,8vw,130px);height:clamp(58px,10cqh,100px);border-radius:22px;flex-shrink:0;font-size:clamp(28px,4vw,60px)}.night-route h3{font-size:clamp(24px,3vw,46px);font-weight:600;line-height:1.2;letter-spacing:-.045em;overflow-wrap:anywhere;margin:0}.night-alerts{flex:0 1 30%;min-height:100px;max-height:200px;overflow:auto;position:relative;scrollbar-width:none}.night-alerts .notice{grid-template-columns:minmax(0,1fr);margin:0;padding:16px 20px;border-radius:18px}.night-alerts .notice h3{font-size:clamp(20px,1.8vw,28px)}.night-alerts .notice p{font-size:clamp(18px,1.5vw,24px);line-height:1.35}.night-alerts .notice-routes{display:flex;gap:8px;margin:8px 0}.night-alerts .notice-routes .route-badge{width:62px;height:38px;border-radius:10px;font-size:22px}.night-alerts .notice-graphic{display:none}.night-alert-page{display:block;text-align:right;color:var(--muted);font-size:14px;padding:6px}.board[data-night=true] .numeric-old,.board[data-night=true] .numeric-next,.board[data-night=true] .numeric-previous{animation:none!important}.board[data-night=true] .numeric-current{animation:none!important}@media(max-width:700px){.night-screen{gap:16px;padding:18px 0}.night-intro h2{font-size:clamp(26px,6.7vw,36px)}.night-content{grid-template-columns:1fr;gap:18px;align-content:center}.night-service{gap:12px}.night-service-time{font-size:clamp(68px,min(22vw,24cqh),115px)}.night-route h3{font-size:clamp(23px,5.7vw,32px)}.night-alerts{max-height:150px;flex-basis:28%}.night-alerts .notice{padding:14px}.night-alerts .notice-meta{flex-wrap:wrap}.night-alerts .notice-period{font-size:13px}.night-alerts .notice p{font-size:18px}}@media(max-height:620px) and (min-width:701px){.night-screen{gap:12px;padding:12px 0}.night-intro h2{font-size:clamp(25px,3vw,36px);margin-top:8px}.night-service-time{font-size:clamp(64px,28cqh,120px)}.night-route h3{font-size:clamp(23px,3vw,36px)}.night-route .route-badge{height:56px;width:80px}.night-alerts{min-height:90px;max-height:110px;flex-basis:28%}.night-service-label{font-size:22px}}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
export type Departure = {
|
export type Departure = {
|
||||||
id: string; routeId: number; routeNumber: string; routeName: string; destination: string;
|
id: string; routeId: number; routeNumber: string; routeName: string; destination: string;
|
||||||
type: string; scheduledAt: string; scheduledTime: string; note: string; source: 'schedule';
|
type: string; scheduledAt: string; scheduledTime: string; note: string; source: 'schedule';
|
||||||
serviceDate?: string; originStopId?: number; originTime?: string;
|
serviceDate?: string; originStopId?: number; originTime?: string; endStopId?: number;
|
||||||
};
|
};
|
||||||
export type Route = { id: number; number: string; name: string; type: string };
|
export type Route = { id: number; number: string; name: string; type: string };
|
||||||
|
export type RouteStop = { id: number; name: string; sequence: number; offsetMinutes: number | null };
|
||||||
export type Cancellation = { routeNumber: string; stopId: number; serviceDate: string; time: string };
|
export type Cancellation = { routeNumber: string; stopId: number; serviceDate: string; time: string };
|
||||||
export type Alert = { id: string; title: string; message: string; type: string; routeNumbers: string[]; routeIds: number[]; startAt: string | null; endAt: string | null; cancellations?: Cancellation[] };
|
export type Alert = { id: string; title: string; message: string; type: string; routeNumbers: string[]; routeIds: number[]; startAt: string | null; endAt: string | null; cancellations?: Cancellation[] };
|
||||||
export type Stop = { id: number; name: string; direction: string };
|
export type Stop = { id: number; name: string; direction: string };
|
||||||
@@ -14,10 +15,11 @@ export type Settings = { stopId: number; demo: boolean; mainSeconds: number; inf
|
|||||||
rowCount: number; routeFilter: string; theme: 'dark' | 'light'; colorScheme: string; lightColorScheme: string;
|
rowCount: number; routeFilter: string; theme: 'dark' | 'light'; colorScheme: string; lightColorScheme: string;
|
||||||
infoServiceEnabled: boolean; infoWeatherEnabled: boolean; infoQrEnabled: boolean;
|
infoServiceEnabled: boolean; infoWeatherEnabled: boolean; infoQrEnabled: boolean;
|
||||||
showNextSameRoute: boolean; longWaitEnabled: boolean; displayProfile: 'tv' | 'street'; viewingDistance: 'near' | 'medium' | 'far';
|
showNextSameRoute: boolean; longWaitEnabled: boolean; displayProfile: 'tv' | 'street'; viewingDistance: 'near' | 'medium' | 'far';
|
||||||
|
nightMode: 'off' | 'auto'; nightWakeMinutes: 45 | 60;
|
||||||
adsEnabled: boolean; adSeconds: number; adTitle: string; adText: string };
|
adsEnabled: boolean; adSeconds: number; adTitle: string; adText: string };
|
||||||
export type Scene = 'departures' | 'info' | 'ad';
|
export type Scene = 'departures' | 'info' | 'ad';
|
||||||
export type Timetable = { version: 1; cityId: number; cityName: string; stop: Stop; routes: (Route & { schedules: {
|
export type Timetable = { version: 1; cityId: number; cityName: string; stop: Stop; routes: (Route & { stops?: RouteStop[]; schedules: {
|
||||||
dayType: string; validFrom: string | null; validUntil: string | null; trips: { arrivalSeconds: number; destination: string; note: string; daysOfWeek: number[]; exceptDaysOfWeek: number[]; originStopId?: number; originTime?: string }[];
|
dayType: string; validFrom: string | null; validUntil: string | null; trips: { arrivalSeconds: number; destination: string; note: string; daysOfWeek: number[]; exceptDaysOfWeek: number[]; originStopId?: number; originTime?: string; endStopId?: number }[];
|
||||||
}[] })[]; holidays: string[]; calendarYears: number[]; updatedAt: string; expiresAt: string };
|
}[] })[]; holidays: string[]; calendarYears: number[]; updatedAt: string; expiresAt: string };
|
||||||
export type Weather = { cityId: number; stopId: number; updatedAt: string; observedAt: string; temperature: number; apparentTemperature: number;
|
export type Weather = { cityId: number; stopId: number; updatedAt: string; observedAt: string; temperature: number; apparentTemperature: number;
|
||||||
code: number; isDay: boolean; windSpeed: number; hourly: { at: string; temperature: number; code: number; precipitationProbability: number }[] };
|
code: number; isDay: boolean; windSpeed: number; hourly: { at: string; temperature: number; code: number; precipitationProbability: number }[] };
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import type { Config, Settings } from './types';
|
import type { Config, Settings } from './types';
|
||||||
import { isManagedState, MANAGED_KEY } from './lib/device.mjs';
|
import { hydrateManagedState, isManagedState, MANAGED_KEY } from './lib/device.mjs';
|
||||||
export type ManagedState = { state: 'paired'; enabled: true; adminUrl: string; config: Config; device: { id: string; name: string; cityId: number; revision: number; settings: Settings } };
|
export type ManagedState = { state: 'paired'; enabled: true; adminUrl: string; config: Config; device: { id: string; name: string; cityId: number; revision: number; settings: Settings } };
|
||||||
type Control = { state: string; enabled?: boolean; code?: string; expiresAt?: string; adminUrl?: string };
|
type Control = { state: string; enabled?: boolean; code?: string; expiresAt?: string; adminUrl?: string };
|
||||||
function cached(): ManagedState | null { try { const value = JSON.parse(localStorage.getItem(MANAGED_KEY) || 'null'); return isManagedState(value) ? value : null; } catch { return null; } }
|
function cached(): ManagedState | null { try { const value = hydrateManagedState(JSON.parse(localStorage.getItem(MANAGED_KEY) || 'null')); return isManagedState(value) ? value : null; } catch { return null; } }
|
||||||
const info = () => ({ width: innerWidth, height: innerHeight, version: 'board-0.1.0' });
|
const info = () => ({ width: innerWidth, height: innerHeight, version: 'board-0.1.0' });
|
||||||
export function useDeviceControl() {
|
export function useDeviceControl() {
|
||||||
const [managed, setManaged] = useState<ManagedState | null>(cached), [control, setControl] = useState<Control>({ state: 'loading' });
|
const [managed, setManaged] = useState<ManagedState | null>(cached), [control, setControl] = useState<Control>({ state: 'loading' });
|
||||||
@@ -38,6 +38,7 @@ export function useDeviceControl() {
|
|||||||
if (['unpaired', 'expired'].includes(value.state)) { if (!stopped) { setManaged(null); applied.current = 0; localStorage.removeItem(MANAGED_KEY); } value = await call('pair', info()); }
|
if (['unpaired', 'expired'].includes(value.state)) { if (!stopped) { setManaged(null); applied.current = 0; localStorage.removeItem(MANAGED_KEY); } value = await call('pair', info()); }
|
||||||
if (value.state === 'paired' && !value.config) value = await call('status');
|
if (value.state === 'paired' && !value.config) value = await call('status');
|
||||||
if (value.state === 'paired') {
|
if (value.state === 'paired') {
|
||||||
|
value = hydrateManagedState(value);
|
||||||
if (!isManagedState(value)) throw new Error('invalid_settings');
|
if (!isManagedState(value)) throw new Error('invalid_settings');
|
||||||
if (!stopped) { setManaged(previous => previous && previous.device.id === value.device.id && previous.device.revision === value.device.revision ? previous : value); setControl(value); setFailed(false); }
|
if (!stopped) { setManaged(previous => previous && previous.device.id === value.device.id && previous.device.revision === value.device.revision ? previous : value); setControl(value); setFailed(false); }
|
||||||
if (Date.now() - lastHeartbeat >= 30000) { await call('heartbeat', { ...info(), appliedRevision: current.current?.device.id === value.device.id ? applied.current : 0 }); lastHeartbeat = Date.now(); }
|
if (Date.now() - lastHeartbeat >= 30000) { await call('heartbeat', { ...info(), appliedRevision: current.current?.device.id === value.device.id ? applied.current : 0 }); lastHeartbeat = Date.now(); }
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { readFile } from 'node:fs/promises';
|
import { readFile } from 'node:fs/promises';
|
||||||
import { isManagedState } from '../src/lib/device.mjs';
|
import { hydrateManagedState, isManagedState } from '../src/lib/device.mjs';
|
||||||
import { createDeviceBridge } from '../server/device.mjs';
|
import { createDeviceBridge } from '../server/device.mjs';
|
||||||
import { createApp } from '../server/index.mjs';
|
import { createApp } from '../server/index.mjs';
|
||||||
const source = await readFile(new URL('../src/lib/storage.ts', import.meta.url), 'utf8');
|
const source = await readFile(new URL('../src/lib/storage.ts', import.meta.url), 'utf8');
|
||||||
@@ -14,6 +14,9 @@ test('managed configuration validation rejects damaged, mismatched or unsupporte
|
|||||||
for (const patch of [{ rowCount: 8 }, { demo: true }, { theme: 'sepia' }, { stopId: 2 }, { colorScheme: 'sky' }, { routeFilter: '-1' }, { holdNear: null }]) assert.equal(isManagedState({ ...value, device: { ...value.device, settings: { ...defaults, ...patch } } }), false);
|
for (const patch of [{ rowCount: 8 }, { demo: true }, { theme: 'sepia' }, { stopId: 2 }, { colorScheme: 'sky' }, { routeFilter: '-1' }, { holdNear: null }]) assert.equal(isManagedState({ ...value, device: { ...value.device, settings: { ...defaults, ...patch } } }), false);
|
||||||
assert.equal(isManagedState({ ...value, config: { ...value.config, refreshSeconds: 0 } }), false);
|
assert.equal(isManagedState({ ...value, config: { ...value.config, refreshSeconds: 0 } }), false);
|
||||||
assert.equal(isManagedState(null), false);
|
assert.equal(isManagedState(null), false);
|
||||||
|
const legacy = structuredClone(value); delete legacy.device.settings.nightMode;delete legacy.device.settings.nightWakeMinutes;
|
||||||
|
assert.equal(isManagedState(legacy),true);assert.equal(hydrateManagedState(legacy).device.settings.nightMode,'auto');
|
||||||
|
assert.equal(isManagedState({...value,device:{...value.device,settings:{...defaults,nightWakeMinutes:5}}}),false);
|
||||||
});
|
});
|
||||||
test('pairing bridge keeps secrets in HttpOnly cookies, binds data to the individual device and rejects cross-site writes', async t => {
|
test('pairing bridge keeps secrets in HttpOnly cookies, binds data to the individual device and rejects cross-site writes', async t => {
|
||||||
const tokenA = 'A'.repeat(43), tokenB = 'B'.repeat(43), calls = [];
|
const tokenA = 'A'.repeat(43), tokenB = 'B'.repeat(43), calls = [];
|
||||||
|
|||||||
34
test/fixtures/night-server.mjs
vendored
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
// Explicit local fixture using the production App and timetable logic.
|
||||||
|
import express from 'express';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { createApp } from '../../server/index.mjs';
|
||||||
|
import { createDeviceBridge } from '../../server/device.mjs';
|
||||||
|
import { localDate, shiftDate, timetableDepartures } from '../../src/lib/timetable.mjs';
|
||||||
|
const scenarios = { evening: '2026-10-10T23:20:00+03:00', overnight: '2026-10-11T00:14:00+03:00', after: '2026-10-11T00:20:00+03:00', night: '2026-10-11T02:00:00+03:00', ninety: '2026-10-11T04:10:00+03:00', wake: '2026-10-11T04:40:00+03:00', wake45: '2026-10-11T04:55:00+03:00', day: '2026-10-11T12:00:00+03:00', avtovokzal: '2026-10-11T03:31:00+03:00', avtovokzalWake60: '2026-10-11T04:15:00+03:00', avtovokzalWake45: '2026-10-11T04:30:00+03:00' };
|
||||||
|
// Optional public timetable snapshot, with explicit fixture clock and fresh TTL.
|
||||||
|
const snapshot = process.env.NIGHT_TIMETABLE_PATH ? JSON.parse(readFileSync(process.env.NIGHT_TIMETABLE_PATH, 'utf8')) : null;
|
||||||
|
const defaultStop = snapshot?.stop || {id:1,name:'Швейная фабрика',direction:'Тестовое расписание'};
|
||||||
|
let scenario = scenarios[process.env.NIGHT_SCENARIO] ? process.env.NIGHT_SCENARIO : 'evening', started = Date.now(), alertsOn = false, expired = false;
|
||||||
|
const now = () => Date.parse(scenarios[scenario]) + Date.now() - started;
|
||||||
|
function timetable(stopId=defaultStop.id) {
|
||||||
|
const updated = now() - (expired ? 8*86400000 : 0);
|
||||||
|
if (snapshot) return {...snapshot,stop:{...snapshot.stop,id:stopId,direction:`Тестовые часы · ${snapshot.stop.direction}`},updatedAt:new Date(updated).toISOString(),expiresAt:new Date(updated+7*86400000).toISOString()};
|
||||||
|
const route={id:6,number:'6',name:'Швейная фабрика — Больница (Новка)',type:'bus'};
|
||||||
|
const trip=(seconds)=>({arrivalSeconds:seconds,destination:'Больница (Новка) (через Аэропорт)',note:'',daysOfWeek:[],exceptDaysOfWeek:[],originStopId:1,originTime:seconds>=86400?'23:45':`${String(Math.floor(seconds/3600)).padStart(2,'0')}:${String(Math.floor(seconds%3600/60)).padStart(2,'0')}`});
|
||||||
|
return {version:1,cityId:1,cityName:'Полоцк',stop:{id:stopId,name:'Швейная фабрика',direction:'Тестовое расписание'},routes:[{...route,schedules:[
|
||||||
|
{dayType:'saturday',validFrom:null,validUntil:null,trips:[trip(23*3600),...(scenario==='overnight'||scenario==='after'?[trip(86400+15*60)]:[])]},
|
||||||
|
{dayType:'sunday',validFrom:null,validUntil:null,trips:[trip(5*3600+40*60),trip(8*3600),trip(19*3600),trip(23*3600)]},
|
||||||
|
{dayType:'weekday',validFrom:null,validUntil:null,trips:[trip(6*3600),trip(23*3600)]}]}],holidays:[],calendarYears:[2026,2027],updatedAt:new Date(updated).toISOString(),expiresAt:new Date(updated+7*86400000).toISOString()};
|
||||||
|
}
|
||||||
|
const client={ config:()=>({cityId:1,cityName:'Полоцк',stopName:defaultStop.name,defaultStopId:defaultStop.id,configured:true,refreshSeconds:5,timeZone:'Europe/Minsk',passengerBaseUrl:'https://pwa.ridebus.by/'}),stops:async()=>[defaultStop],timetable:async id=>timetable(id),coordinates:async()=>({latitude:55,longitude:28}),
|
||||||
|
board:async id=>{const t=timetable(id);return {cityId:1,cityName:'Полоцк',stop:t.stop,routes:t.routes.map(({schedules,...r})=>r),departures:timetableDepartures(t,localDate(now())).filter(d=>Date.parse(d.scheduledAt)+60000>now()),alerts:alertsOn?[{id:'fixture-cancel',type:'cancellation',routeNumbers:['6'],routeIds:[6],title:'Изменение утренних рейсов',message:'Рейс №6 в 05:40 отменён. Первое отправление — в 08:00. Остальные рейсы выполняются по расписанию.',startAt:'2026-10-11T00:00:00+03:00',endAt:'2026-10-12T00:00:00+03:00',cancellations:[{routeNumber:'6',stopId:1,serviceDate:'2026-10-11',time:'05:40'}]}]:[],alertsAvailable:true,partial:false,updatedAt:new Date(now()).toISOString(),serviceDate:localDate(now()),mode:'live',serverTime:new Date(now()).toISOString()};}};
|
||||||
|
// createApp supplies real wall time by default. This fixture explicitly replaces
|
||||||
|
// serverTime so the production client exercises schedule boundaries deterministically.
|
||||||
|
const app=express();app.use('/api/board',async(req,res,next)=>{if(req.method==='GET')res.setHeader('Cache-Control','no-store');if(req.method==='GET'&&req.query.stopId===String(defaultStop.id))return res.json(await client.board(defaultStop.id));next();});
|
||||||
|
app.use(createApp(client,{weather:async(cityId,stopId)=>({cityId,stopId,updatedAt:new Date(now()).toISOString(),observedAt:new Date(now()).toISOString(),temperature:7,apparentTemperature:5,code:3,isDay:false,windSpeed:2,hourly:[]})},createDeviceBridge({enabled:false})));
|
||||||
|
app.use('/__fixture',express.urlencoded({extended:false}));
|
||||||
|
app.post('/__fixture',(req,res)=>{if(scenarios[req.body.scenario]){scenario=req.body.scenario;started=Date.now();}if(req.body.alerts!==undefined)alertsOn=req.body.alerts==='true';if(req.body.expired!==undefined)expired=req.body.expired==='true';res.redirect(303,'/__fixture');});
|
||||||
|
app.get('/__fixture',(_req,res)=>res.type('html').send(`<html lang="ru"><body style="font:20px system-ui;padding:32px"><h1>Тестовые сценарии ночного режима</h1><p>Текущий: ${scenario}; уведомление: ${alertsOn}; истёкший кеш: ${expired}</p><form method="post">${Object.keys(scenarios).map(s=>`<button style="padding:14px;margin:6px" name="scenario" value="${s}">${s}</button>`).join('')}<button name="alerts" value="true">Показать отмену</button><button name="alerts" value="false">Убрать отмену</button><button name="expired" value="true">Истёкший кеш</button><button name="expired" value="false">Свежий кеш</button></form><p>Часы приложения обновятся при опросе (5 секунд). После смены полного расписания обновите страницу табло.</p></body></html>`));
|
||||||
|
const dist=fileURLToPath(new URL('../../dist/',import.meta.url));app.use(express.static(dist));app.get('/{*path}',(_req,res)=>res.sendFile(`${dist}/index.html`));
|
||||||
|
await new Promise(resolve=>app.listen(5193,'127.0.0.1',resolve));console.log('Night fixture http://127.0.0.1:5193/ ; controls /__fixture');
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { boardScenes, informationBlocks, serviceWindow } from '../src/lib/information.ts';
|
import { boardScenes, informationBlocks } from '../src/lib/information.ts';
|
||||||
import { defaultSettings } from '../src/lib/storage.ts';
|
import { defaultSettings } from '../src/lib/storage.ts';
|
||||||
|
|
||||||
const off = { ...defaultSettings, infoServiceEnabled: false, infoWeatherEnabled: false, infoQrEnabled: false };
|
const off = { ...defaultSettings, infoServiceEnabled: false, infoWeatherEnabled: false, infoQrEnabled: false };
|
||||||
const trip = (routeId, at) => ({ routeId, routeNumber: String(routeId), scheduledAt: at });
|
|
||||||
|
|
||||||
test('disabled information blocks are omitted without leaving gaps in the cycle', () => {
|
test('disabled information blocks are omitted without leaving gaps in the cycle', () => {
|
||||||
assert.deepEqual(informationBlocks(defaultSettings).map(block => block.id), ['service', 'weather', 'qr']);
|
assert.deepEqual(informationBlocks(defaultSettings).map(block => block.id), ['service', 'weather', 'qr']);
|
||||||
@@ -18,25 +17,3 @@ test('all blocks off keeps departures, retains optional advertising, and still s
|
|||||||
assert.deepEqual(boardScenes({ ...off, adsEnabled: true }, true, true), ['departures', 'info']);
|
assert.deepEqual(boardScenes({ ...off, adsEnabled: true }, true, true), ['departures', 'info']);
|
||||||
assert.deepEqual(boardScenes(off, true, false), ['departures', 'info']);
|
assert.deepEqual(boardScenes(off, true, false), ['departures', 'info']);
|
||||||
});
|
});
|
||||||
test('service boundaries use local arrival dates, including after-midnight trips', () => {
|
|
||||||
const now = Date.parse('2026-10-09T23:00:00+03:00');
|
|
||||||
const last = trip(2, '2026-10-09T23:50:00+03:00');
|
|
||||||
const first = trip(1, '2026-10-09T21:15:00Z'); // 00:15 tomorrow in Minsk
|
|
||||||
const departures = [trip(1, '2026-10-10T06:00:00+03:00'), last, first,
|
|
||||||
trip(1, '2026-10-09T00:10:00+03:00'), trip(1, '2026-10-08T23:59:00+03:00')];
|
|
||||||
assert.deepEqual(serviceWindow(departures, now, ''), { lastToday: last, firstTomorrow: first });
|
|
||||||
});
|
|
||||||
test('route selection scopes both boundaries; empty days do not invent a trip', () => {
|
|
||||||
const now = Date.parse('2026-10-09T12:00:00+03:00');
|
|
||||||
const filtered = trip(1, '2026-10-09T20:00:00+03:00');
|
|
||||||
const departures = [filtered, trip(2, '2026-10-09T23:00:00+03:00'), trip(2, '2026-10-10T05:00:00+03:00')];
|
|
||||||
assert.deepEqual(serviceWindow(departures, now, '1'), { lastToday: filtered, firstTomorrow: null });
|
|
||||||
assert.deepEqual(serviceWindow([], now, ''), { lastToday: null, firstTomorrow: null });
|
|
||||||
});
|
|
||||||
test('service boundaries roll to the new day and across the year at local midnight', () => {
|
|
||||||
const last = trip(1, '2026-12-31T23:59:00+03:00');
|
|
||||||
const first = trip(1, '2027-01-01T05:00:00+03:00');
|
|
||||||
const next = trip(1, '2027-01-02T06:00:00+03:00');
|
|
||||||
assert.deepEqual(serviceWindow([last, first, next], Date.parse('2026-12-31T23:59:00+03:00'), ''), { lastToday: last, firstTomorrow: first });
|
|
||||||
assert.deepEqual(serviceWindow([last, first, next], Date.parse('2027-01-01T00:00:00+03:00'), ''), { lastToday: first, firstTomorrow: next });
|
|
||||||
});
|
|
||||||
|
|||||||
80
test/journey.test.mjs
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { loadTimetable } from '../server/timetable.mjs';
|
||||||
|
import { isTimetable, timetableDepartures } from '../src/lib/timetable.mjs';
|
||||||
|
import { onwardJourney } from '../src/lib/journey.ts';
|
||||||
|
import { displayDepartures, firstAvailable } from '../src/lib/departures.ts';
|
||||||
|
import { readTimetable, writeTimetable } from '../src/lib/storage.ts';
|
||||||
|
|
||||||
|
const now = Date.parse('2026-10-08T11:00:00+03:00');
|
||||||
|
const route = { id: 37, route_number: '5', name: 'А — З', type: 'bus' };
|
||||||
|
const stops = ['А', 'Б', 'В', 'Г', 'Д', 'Е', 'Ж', 'З'].map((name, i) => ({ stop_id: i + 1, name, sequence: i + 1, time_offset_minutes: i * 3 }));
|
||||||
|
async function bundle(trips = [{ departureTime: '12:00' }], rawStops = stops) {
|
||||||
|
return loadTimetable({ cityId: 1, cityName: 'Полоцк', stop: { id: 2, name: 'Б', routes: [route] }, now,
|
||||||
|
upstream: async path => path.includes('holidays') ? [] : path.endsWith('/stops') ? rawStops : [{ route_id: 37, day_type: 'weekday', trips }] });
|
||||||
|
}
|
||||||
|
const first = data => timetableDepartures(data, '2026-10-08')[0];
|
||||||
|
const journey = (data, departure = first(data), clock = now, cityId = 1, stopId = 2) => onwardJourney(data, departure, clock, cityId, stopId);
|
||||||
|
|
||||||
|
test('full timetable caches ordered route stops once and shows four downstream stops with time since boarding', async () => {
|
||||||
|
const data = await bundle(undefined, [...stops].reverse());
|
||||||
|
assert.equal(isTimetable(data, 1, 2), true);
|
||||||
|
assert.deepEqual(data.routes[0].stops.map(stop => stop.id), [1,2,3,4,5,6,7,8]);
|
||||||
|
assert.equal(first(data).endStopId, 8);
|
||||||
|
assert.deepEqual(journey(data), { stops: [{id:3,name:'В',minutes:3},{id:4,name:'Г',minutes:6},{id:5,name:'Д',minutes:9},{id:6,name:'Е',minutes:12}], more:2 });
|
||||||
|
});
|
||||||
|
test('partial journeys end at their actual terminal, and a terminal stop does not imply a return service', async () => {
|
||||||
|
const partial = await bundle([{departureTime:'12:00',startStopId:1,endStopId:3}]);
|
||||||
|
assert.deepEqual(journey(partial), {stops:[{id:3,name:'В',minutes:3}],more:0});
|
||||||
|
const terminal = await bundle([{departureTime:'12:00',startStopId:1,endStopId:2}]);
|
||||||
|
assert.deepEqual(journey(terminal), {stops:[],more:0});
|
||||||
|
});
|
||||||
|
test('reverse and shortened reverse trips show decreasing stop sequence with positive duration', async () => {
|
||||||
|
const data = await bundle([{departureTime:'12:00',startStopId:8,endStopId:1}]);
|
||||||
|
assert.equal(first(data).destination, 'А');
|
||||||
|
assert.deepEqual(journey(data), {stops:[{id:1,name:'А',minutes:3}],more:0});
|
||||||
|
});
|
||||||
|
test('after-midnight trips retain service-day and terminal identity for the diagram', async () => {
|
||||||
|
const data = await bundle([{departureTime:'23:59'}]);
|
||||||
|
const departure = timetableDepartures(data, '2026-10-09')[0];
|
||||||
|
assert.equal(departure.scheduledTime, '00:02');assert.equal(departure.serviceDate,'2026-10-08');
|
||||||
|
assert.equal(journey(data,departure,Date.parse('2026-10-09T00:00:00+03:00')).stops[0].name,'В');
|
||||||
|
});
|
||||||
|
test('missing and nonmonotonic offsets preserve names without inventing zero or negative travel times', async () => {
|
||||||
|
for (const changed of [null, -1, 'not-a-number', 1]) {
|
||||||
|
const data = await bundle(undefined, stops.map(stop => stop.stop_id === 4 ? {...stop,time_offset_minutes:changed} : stop));
|
||||||
|
assert.equal(isTimetable(data,1,2),true);
|
||||||
|
assert.deepEqual(journey(data).stops.map(stop => stop.minutes), [null,null,null,null]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test('legacy caches remain valid; unknown endpoints, ambiguous stop identity, stale and foreign bundles hide the diagram', async () => {
|
||||||
|
const data = await bundle();
|
||||||
|
const legacy = structuredClone(data);delete legacy.routes[0].stops;delete legacy.routes[0].schedules[0].trips[0].endStopId;
|
||||||
|
assert.equal(isTimetable(legacy,1,2),true);assert.equal(journey(legacy),null);
|
||||||
|
assert.equal(journey(data,{...first(data),endStopId:999}),null);
|
||||||
|
assert.equal(journey(data,{...first(data),originStopId:6,endStopId:8}),null);
|
||||||
|
assert.equal(journey(data,first(data),now,2,2),null);
|
||||||
|
assert.equal(journey(data,first(data),now,1,3),null);
|
||||||
|
assert.equal(journey(data,first(data),now+7*86400000),null);
|
||||||
|
const ambiguous = await bundle(undefined, [...stops, {...stops[0],sequence:9}]);
|
||||||
|
assert.deepEqual(ambiguous.routes[0].stops, []);assert.equal(journey(ambiguous),null);
|
||||||
|
assert.equal(isTimetable({...data,routes:[{...data.routes[0],stops:[...data.routes[0].stops,data.routes[0].stops[0]]}]},1,2),false);
|
||||||
|
});
|
||||||
|
test('the diagram follows the next available trip after cancellation, including its different terminal', async () => {
|
||||||
|
const data = await bundle([{departureTime:'12:00',endStopId:8},{departureTime:'12:10',endStopId:3}]);
|
||||||
|
const departures = timetableDepartures(data,'2026-10-08');
|
||||||
|
const alerts = [{id:'cancel',type:'cancellation',routeIds:[37],routeNumbers:['5'],cancellations:[{routeNumber:'5',stopId:1,serviceDate:'2026-10-08',time:'12:00'}]}];
|
||||||
|
const displayed = displayDepartures(departures, alerts, 2, now, true);
|
||||||
|
const departure = firstAvailable(displayed);
|
||||||
|
assert.equal(departure.scheduledTime,'12:13');
|
||||||
|
assert.deepEqual(journey(data,departure),{stops:[{id:3,name:'В',minutes:3}],more:0});
|
||||||
|
});
|
||||||
|
test('offline round-trip preserves route topology and per-trip terminal metadata', async () => {
|
||||||
|
const values = new Map();
|
||||||
|
globalThis.localStorage = {getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)};
|
||||||
|
globalThis.indexedDB = {open:()=>{throw new Error('Unavailable');}};
|
||||||
|
const data = await bundle();await writeTimetable(data);
|
||||||
|
const saved = await readTimetable(1,2);
|
||||||
|
assert.deepEqual(journey(saved),journey(data));
|
||||||
|
assert.equal(await readTimetable(2,2),null);assert.equal(await readTimetable(1,3),null);
|
||||||
|
});
|
||||||
79
test/night.test.mjs
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { nightWindow } from '../src/lib/night.ts';
|
||||||
|
import { defaultSettings } from '../src/lib/storage.ts';
|
||||||
|
const at = value => Date.parse(`${value}+03:00`);
|
||||||
|
const trip = (id, time, serviceDate = time.slice(0,10), status = 'scheduled', routeId = 1) => ({ id, routeId, routeNumber: String(routeId), routeName: 'Маршрут', destination: 'Автовокзал', type: 'bus', scheduledAt: new Date(at(time)).toISOString(), scheduledTime: time.slice(11,16), serviceDate, source: 'schedule', note: '', status });
|
||||||
|
const trips = [trip('last', '2026-10-10T23:00:00'), trip('first', '2026-10-11T05:40:00'), trip('later', '2026-10-11T22:00:00')];
|
||||||
|
const check = (now, departures = trips, settings = defaultSettings, available = true) => nightWindow(settings, departures, at(now), available);
|
||||||
|
test('night starts only after the final scheduled trip and an overnight break longer than two hours', () => {
|
||||||
|
assert.equal(check('2026-10-10T22:59:00').active, false);
|
||||||
|
assert.equal(check('2026-10-10T23:00:30').active, false);
|
||||||
|
const night = check('2026-10-10T23:02:00'); assert.equal(night.active, true); assert.equal(night.first.id, 'first'); assert.equal(night.completedToday, true);
|
||||||
|
assert.equal(check('2026-10-11T03:40:00').active, true);
|
||||||
|
const short = [trips[0], trip('short', '2026-10-11T01:00:00'), trips[2]];
|
||||||
|
assert.equal(check('2026-10-10T23:02:00', short).active, false);
|
||||||
|
assert.equal(check('2026-10-10T23:02:00', [trips[0], trip('long', '2026-10-11T01:01:00'), trips[2]]).active, true);
|
||||||
|
});
|
||||||
|
test('a daytime gap does not declare movement complete', () => {
|
||||||
|
const day = [trip('early','2026-10-11T06:00:00'),trip('late','2026-10-11T19:00:00')];
|
||||||
|
assert.equal(check('2026-10-11T12:00:00', day).active, false);
|
||||||
|
});
|
||||||
|
test('cold starts during the same break stay in night mode until the selected 60 or 45 minute boundary', () => {
|
||||||
|
assert.equal(check('2026-10-11T04:10:00').active,true);
|
||||||
|
assert.equal(check('2026-10-11T04:39:59').active,true);
|
||||||
|
assert.equal(check('2026-10-11T04:40:00').active,false);
|
||||||
|
assert.equal(check('2026-10-11T04:50:00',trips,{...defaultSettings,nightWakeMinutes:45}).active,true);
|
||||||
|
assert.equal(check('2026-10-11T04:55:00',trips,{...defaultSettings,nightWakeMinutes:45}).active,false);
|
||||||
|
});
|
||||||
|
test('midnight keeps night mode and the correct first trip date', () => {
|
||||||
|
const before = check('2026-10-10T23:40:00'), after = check('2026-10-11T00:05:00');
|
||||||
|
assert.equal(after.active,true);assert.equal(after.first.id,before.first.id);assert.equal(after.completedToday,false);assert.equal(after.first.scheduledTime,'05:40');
|
||||||
|
});
|
||||||
|
test('after-midnight arrivals keep their service day and cannot be hidden while approaching', () => {
|
||||||
|
const overnight = [trip('late','2026-10-11T00:15:00','2026-10-10'),...trips];
|
||||||
|
assert.equal(check('2026-10-11T00:14:00',overnight).active,false);
|
||||||
|
assert.equal(check('2026-10-11T00:15:30',overnight).active,false);
|
||||||
|
assert.equal(check('2026-10-11T00:17:00',overnight).active,true);
|
||||||
|
});
|
||||||
|
test('a calendar-day 00:40 continuation closes the previous night, while continuous overnight service and midday gaps do not', () => {
|
||||||
|
const closing = [trip('yesterday','2026-10-10T23:55:00'),trip('tail','2026-10-11T00:40:00'),trip('morning','2026-10-11T05:44:00'),trip('end','2026-10-11T23:00:00')];
|
||||||
|
assert.equal(check('2026-10-11T00:39:00',closing).active,false);
|
||||||
|
assert.equal(check('2026-10-11T00:40:30',closing).active,false);
|
||||||
|
const night=check('2026-10-11T02:00:00',closing);assert.equal(night.active,true);assert.equal(night.first.id,'morning');
|
||||||
|
assert.equal(check('2026-10-11T04:00:00',closing).active,true);
|
||||||
|
assert.equal(check('2026-10-11T04:44:00',closing).active,false);
|
||||||
|
assert.equal(check('2026-10-11T12:00:00',closing).active,false);
|
||||||
|
const continuous=[...closing,trip('one','2026-10-11T01:30:00'),trip('two','2026-10-11T02:20:00'),trip('three','2026-10-11T03:10:00')];
|
||||||
|
assert.equal(check('2026-10-11T04:00:00',continuous).active,false);
|
||||||
|
});
|
||||||
|
test('Avtovokzal midnight trip at 00:31 does not prevent night mode on startup at 03:31 before the 05:15 trip', () => {
|
||||||
|
const departures = [trip('last', '2026-10-10T23:55:00'), trip('tail', '2026-10-11T00:31:00'), trip('morning', '2026-10-11T05:15:00'), trip('later', '2026-10-11T23:55:00')];
|
||||||
|
assert.equal(check('2026-10-11T00:30:00', departures).active, false);
|
||||||
|
assert.equal(check('2026-10-11T00:31:59', departures).active, false);
|
||||||
|
const night = check('2026-10-11T03:31:00', departures);
|
||||||
|
assert.equal(night.active, true); assert.equal(night.first.scheduledTime, '05:15'); assert.equal(night.completedToday, false);
|
||||||
|
assert.equal(check('2026-10-11T04:14:59', departures).active, true);
|
||||||
|
assert.equal(check('2026-10-11T04:15:00', departures).active, false);
|
||||||
|
const wake45 = {...defaultSettings, nightWakeMinutes:45};
|
||||||
|
assert.equal(check('2026-10-11T04:15:00', departures, wake45).active, true);
|
||||||
|
assert.equal(check('2026-10-11T04:30:00', departures, wake45).active, false);
|
||||||
|
});
|
||||||
|
test('cancelled first trip is skipped; all cancelled, missing, stale, demo and disabled data cannot start night', () => {
|
||||||
|
const cancelled = [trips[0],{...trips[1],status:'cancelled'},trip('next','2026-10-11T06:00:00')];
|
||||||
|
assert.equal(check('2026-10-11T00:05:00',cancelled).first.id,'next');
|
||||||
|
for(const data of [[],[trips[0]],trips.map(d=>({...d,status:'cancelled'}))])assert.equal(check('2026-10-11T00:05:00',data).active,false);
|
||||||
|
assert.equal(check('2026-10-11T00:05:00',trips,defaultSettings,false).active,false);
|
||||||
|
assert.equal(check('2026-10-11T00:05:00',trips,{...defaultSettings,demo:true}).active,false);
|
||||||
|
assert.equal(check('2026-10-11T00:05:00',trips,{...defaultSettings,nightMode:'off'}).active,false);
|
||||||
|
});
|
||||||
|
test('route selection recalculates the break; unordered data and year boundaries are safe', () => {
|
||||||
|
const mixed = [trip('other','2026-10-11T00:20:00','2026-10-10','scheduled',2),trip('other-first','2026-10-11T04:00:00','2026-10-11','scheduled',2),...trips];
|
||||||
|
assert.equal(check('2026-10-11T00:10:00',mixed).active,false);
|
||||||
|
const filteredSettings = {...defaultSettings,routeFilter:'1'};
|
||||||
|
assert.equal(check('2026-10-11T00:10:00',mixed,filteredSettings).active,true);
|
||||||
|
assert.equal(check('2026-10-11T03:31:00',mixed).active,false);
|
||||||
|
assert.equal(check('2026-10-11T03:31:00',mixed,filteredSettings).active,true);
|
||||||
|
const year = [trip('year-last','2026-12-31T23:00:00'),trip('year-first','2027-01-01T06:00:00')].reverse();
|
||||||
|
assert.equal(check('2027-01-01T00:10:00',year).first.id,'year-first');
|
||||||
|
});
|
||||||
@@ -2,6 +2,13 @@ import test from 'node:test';
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { readSnapshot, writeSnapshot, readTimetable, writeTimetable, readWeather, writeWeather, readSettings, saveSettings } from '../src/lib/storage.ts';
|
import { readSnapshot, writeSnapshot, readTimetable, writeTimetable, readWeather, writeWeather, readSettings, saveSettings } from '../src/lib/storage.ts';
|
||||||
|
|
||||||
|
test('night settings migrate and persist, with invalid values falling back to supported defaults', () => {
|
||||||
|
const values = new Map(); globalThis.localStorage = { getItem:key=>values.get(key)||null, setItem:(key,value)=>values.set(key,value) };
|
||||||
|
const old = readSettings();assert.equal(old.nightMode,'auto');assert.equal(old.nightWakeMinutes,60);
|
||||||
|
saveSettings({...old,nightMode:'off',nightWakeMinutes:45});assert.equal(readSettings().nightMode,'off');assert.equal(readSettings().nightWakeMinutes,45);
|
||||||
|
values.set('ridebus-board-settings-v1',JSON.stringify({nightMode:'always',nightWakeMinutes:5}));assert.equal(readSettings().nightMode,'auto');assert.equal(readSettings().nightWakeMinutes,60);
|
||||||
|
});
|
||||||
|
|
||||||
const data = { cityId: 1, stop: { id: 1, name: 'Швейная фабрика' }, mode: 'live', serviceDate: '2026-10-08', updatedAt: '2026-10-08T12:00:00+03:00', routes: [], alerts: [],
|
const data = { cityId: 1, stop: { id: 1, name: 'Швейная фабрика' }, mode: 'live', serviceDate: '2026-10-08', updatedAt: '2026-10-08T12:00:00+03:00', routes: [], alerts: [],
|
||||||
departures: [{ id: '1:12:30', scheduledAt: '2026-10-08T12:30:00+03:00' }] };
|
departures: [{ id: '1:12:30', scheduledAt: '2026-10-08T12:30:00+03:00' }] };
|
||||||
|
|
||||||
|
|||||||