diff --git a/README.md b/README.md index 5a7ee68..80d48fc 100644 --- a/README.md +++ b/README.md @@ -22,9 +22,10 @@ npm start - Точная отмена выделяется непосредственно в строке рейса: время остаётся видимым, отсчёт заменяется пометкой «Отменён». Ближайшим становится следующий доступный рейс. Общие сообщения без однозначного времени и остановки показывают «Есть изменения» и сохраняют исходный текст предупреждения. - Под ближайшим отправлением — время следующего рейса того же маршрута в том же направлении. Отменённые отправления пропускаются, завтрашняя дата отмечается один раз. Переключатель находится в настройках → «Экраны». - При ожидании от 90 минут крупно показывается фактическое время отправления с подписью «Сегодня в» или «Завтра в». При приближении рейса возвращается обычный отсчёт. Режим можно отключить в настройках → «Экраны». +- Автоматический ночной режим включается после завершения движения по полному расписанию, если весь ночной перерыв до первого следующего рейса длиннее двух часов. На чёрном фоне — первый рейс, направление, часы без секунд и актуальные уведомления. Информация и реклама не сменяют этот экран. При оставшихся 60 минутах возвращается обычное табло; в настройках → «Экраны» можно выбрать 45 минут или выключить режим. Дневная тема и палитра восстанавливаются автоматически. По умолчанию режим автоматический, возврат за 60 минут. - Настройки → «Оформление» → «Читаемость»: профили «Телевизор» / «Уличное табло» и расстояние «Близко» / «Средне» / «Далеко». Для улицы и дальнего просмотра текст крупнее, одновременно видимых строк меньше. Уличный профиль использует спокойное короткое появление вместо вертикальной прокрутки текста. Информационные карточки при дальнем просмотре показываются по одной. - В шапке — название и направление конкретной площадки из API, часы и дата в `Europe/Minsk`. Поле «основная» не выдаётся за направление; если направления нет, оно не придумывается. Город, подпись «Остановка», «RideBus API» и «Все направления» убраны из шапки. -- Информационный экран: первый и последний рейс, погода, QR-код и предупреждения. Карточка движения показывает последний рейс сегодня и первый завтра с номером и направлением по полному расписанию, с учётом фильтра маршрута и прибытий после полуночи. Без полного расписания время не подставляется из короткого списка ближайших рейсов. Часы и ближайший рейс остаются видимыми. В вертикальной и короткой компоновке карточки сменяются при следующих посещениях экрана; их также можно выбрать кнопками «Движение», «Погода», «RideBus». +- Информационный экран: «Дальше по маршруту», погода, QR-код и предупреждения. Схема показывает до четырёх следующих остановок ближайшего доступного рейса и время пути от текущей остановки по расписанию. Учитывает выбранный маршрут, отмены, укороченные и обратные рейсы; на конечной не придумывает продолжение. Неизвестное время отмечено «—». Горизонтальная линия плавно сдвигается влево, выделенное название остановки увеличивается, соседние остаются компактными — по мотивам [инфосистемы «Квант»](https://www.artlebedev.ru/kvant/moscow/). Это обзор поездки после посадки, а не положение автобуса: остановки сменяются каждые 4 секунды (6 для уличного профиля), сцена автоматически удлиняется до полного обзора. В узкой карточке название и схема подстраиваются под доступное место. Часы и ближайший рейс остаются видимыми. В вертикальной и короткой компоновке карточки сменяются при следующих посещениях экрана; их также можно выбрать кнопками «Маршрут», «Погода», «RideBus». - В настройках → «Экраны» → «Блоки информации» каждый из трёх блоков можно включить или отключить. Оставшиеся карточки занимают доступную ширину, отключённые пропускаются в компактном цикле. При отключении всех блоков информационный экран исключается из обычного цикла; активные предупреждения о движении всё равно показываются. Выбор сохраняется в браузере; «Сбросить цикл» включает все блоки. - Уведомления об отменах, объездах, задержках и другие сообщения оформлены простой карточкой с иконкой, номерами маршрутов и сроком действия. Исходные заголовки, тексты и детали отмен сохраняются. Статус предупреждений отображается только при их недоступности. - Цикл 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 отсутствует. Изменения кода выкладывайте целой сборкой; 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`, поэтому существующие настройки локального и удалённого управления совместимы; миграция БД не требуется. diff --git a/artifacts/journey-1079x591.jpg b/artifacts/journey-1079x591.jpg new file mode 100644 index 0000000..b658529 Binary files /dev/null and b/artifacts/journey-1079x591.jpg differ diff --git a/artifacts/journey-390x667.jpg b/artifacts/journey-390x667.jpg new file mode 100644 index 0000000..dacd6e1 Binary files /dev/null and b/artifacts/journey-390x667.jpg differ diff --git a/artifacts/journey-879x593.jpg b/artifacts/journey-879x593.jpg new file mode 100644 index 0000000..0b800ce Binary files /dev/null and b/artifacts/journey-879x593.jpg differ diff --git a/artifacts/journey-kvant-1079x591.jpg b/artifacts/journey-kvant-1079x591.jpg new file mode 100644 index 0000000..c2ddd22 Binary files /dev/null and b/artifacts/journey-kvant-1079x591.jpg differ diff --git a/artifacts/journey-kvant-390x667.jpg b/artifacts/journey-kvant-390x667.jpg new file mode 100644 index 0000000..92111c8 Binary files /dev/null and b/artifacts/journey-kvant-390x667.jpg differ diff --git a/artifacts/journey-kvant-879x593.jpg b/artifacts/journey-kvant-879x593.jpg new file mode 100644 index 0000000..8457d21 Binary files /dev/null and b/artifacts/journey-kvant-879x593.jpg differ diff --git a/artifacts/journey-kvant-focus-three.jpg b/artifacts/journey-kvant-focus-three.jpg new file mode 100644 index 0000000..fb1f83a Binary files /dev/null and b/artifacts/journey-kvant-focus-three.jpg differ diff --git a/artifacts/journey-kvant-light.jpg b/artifacts/journey-kvant-light.jpg new file mode 100644 index 0000000..e4f26cf Binary files /dev/null and b/artifacts/journey-kvant-light.jpg differ diff --git a/artifacts/journey-kvant-portrait-long.jpg b/artifacts/journey-kvant-portrait-long.jpg new file mode 100644 index 0000000..3404004 Binary files /dev/null and b/artifacts/journey-kvant-portrait-long.jpg differ diff --git a/artifacts/journey-kvant-repaint.jpg b/artifacts/journey-kvant-repaint.jpg new file mode 100644 index 0000000..376825b Binary files /dev/null and b/artifacts/journey-kvant-repaint.jpg differ diff --git a/artifacts/journey-kvant-three-cards.jpg b/artifacts/journey-kvant-three-cards.jpg new file mode 100644 index 0000000..b253037 Binary files /dev/null and b/artifacts/journey-kvant-three-cards.jpg differ diff --git a/artifacts/journey-kvant-wide.jpg b/artifacts/journey-kvant-wide.jpg new file mode 100644 index 0000000..52d9d02 Binary files /dev/null and b/artifacts/journey-kvant-wide.jpg differ diff --git a/artifacts/journey-light.jpg b/artifacts/journey-light.jpg new file mode 100644 index 0000000..a766b01 Binary files /dev/null and b/artifacts/journey-light.jpg differ diff --git a/artifacts/journey-portrait-last-page.jpg b/artifacts/journey-portrait-last-page.jpg new file mode 100644 index 0000000..3e774c3 Binary files /dev/null and b/artifacts/journey-portrait-last-page.jpg differ diff --git a/artifacts/journey-three-cards.jpg b/artifacts/journey-three-cards.jpg new file mode 100644 index 0000000..d7978e9 Binary files /dev/null and b/artifacts/journey-three-cards.jpg differ diff --git a/artifacts/night-1280x900.jpg b/artifacts/night-1280x900.jpg new file mode 100644 index 0000000..38f7b3d Binary files /dev/null and b/artifacts/night-1280x900.jpg differ diff --git a/artifacts/night-1920x1080.jpg b/artifacts/night-1920x1080.jpg new file mode 100644 index 0000000..38f191d Binary files /dev/null and b/artifacts/night-1920x1080.jpg differ diff --git a/artifacts/night-390x667.jpg b/artifacts/night-390x667.jpg new file mode 100644 index 0000000..8c6da6b Binary files /dev/null and b/artifacts/night-390x667.jpg differ diff --git a/artifacts/night-879x593.jpg b/artifacts/night-879x593.jpg new file mode 100644 index 0000000..9d56f75 Binary files /dev/null and b/artifacts/night-879x593.jpg differ diff --git a/artifacts/night-admin-settings.jpg b/artifacts/night-admin-settings.jpg new file mode 100644 index 0000000..3439483 Binary files /dev/null and b/artifacts/night-admin-settings.jpg differ diff --git a/artifacts/night-avtovokzal-reload.jpg b/artifacts/night-avtovokzal-reload.jpg new file mode 100644 index 0000000..81c8825 Binary files /dev/null and b/artifacts/night-avtovokzal-reload.jpg differ diff --git a/artifacts/night-live.jpg b/artifacts/night-live.jpg new file mode 100644 index 0000000..a474572 Binary files /dev/null and b/artifacts/night-live.jpg differ diff --git a/artifacts/night-no-qr.jpg b/artifacts/night-no-qr.jpg new file mode 100644 index 0000000..d0090f3 Binary files /dev/null and b/artifacts/night-no-qr.jpg differ diff --git a/artifacts/night-wake-light.jpg b/artifacts/night-wake-light.jpg new file mode 100644 index 0000000..d0f1472 Binary files /dev/null and b/artifacts/night-wake-light.jpg differ diff --git a/artifacts/verification-journey.md b/artifacts/verification-journey.md new file mode 100644 index 0000000..11d5ac4 --- /dev/null +++ b/artifacts/verification-journey.md @@ -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 сброшен. diff --git a/artifacts/verification-night-mode.md b/artifacts/verification-night-mode.md new file mode 100644 index 0000000..67d18a9 --- /dev/null +++ b/artifacts/verification-night-mode.md @@ -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 обоих проектов обновлены. Блок первого и последнего рейса в «Информации» пока не заменён: пользователю предложена схема следующих остановок ближайшего автобуса. diff --git a/server/timetable.mjs b/server/timetable.mjs index 1a486f9..e965404 100644 --- a/server/timetable.mjs +++ b/server/timetable.mjs @@ -1,6 +1,19 @@ import { ApiError } from './board.mjs'; import { TIMETABLE_MAX_MS, localDate, shiftDate, validDate } from '../src/lib/timetable.mjs'; +function normalizeStops(raw) { + if (!Array.isArray(raw)) return []; + const stops = raw.map(stop => { + const offset = stop.time_offset_minutes == null || stop.time_offset_minutes === '' ? NaN : Number(stop.time_offset_minutes); + return { id: Number(stop.stop_id), name: typeof stop.name === 'string' ? stop.name.trim() : '', sequence: Number(stop.sequence), + offsetMinutes: Number.isFinite(offset) && offset >= 0 && offset < 1440 ? offset : null }; + }); + // An ambiguous loop or incomplete stop identity cannot produce a reliable diagram. + if (stops.some(stop => !Number.isSafeInteger(stop.id) || stop.id < 1 || !stop.name || !Number.isSafeInteger(stop.sequence) || stop.sequence < 0) + || new Set(stops.map(stop => stop.id)).size !== stops.length || new Set(stops.map(stop => stop.sequence)).size !== stops.length) return []; + return stops.sort((a, b) => a.sequence - b.sequence); +} + function normalizeSchedules(raw, route, rawStops, stopId) { if (!Array.isArray(raw) || !Array.isArray(rawStops)) throw new ApiError(); const stops = new Map(rawStops.map(stop => [Number(stop.stop_id), stop])); @@ -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 origin = start || [...rawStops].sort((a, b) => Number(a.sequence) - Number(b.sequence))[0]; return [{ arrivalSeconds: Number(time[1]) * 3600 + Number(time[2]) * 60 + Number(time[3] || 0) + offset * 60, - originStopId: Number(origin?.stop_id), originTime: `${time[1].padStart(2, '0')}:${time[2]}`, + originStopId: Number(origin?.stop_id), originTime: `${time[1].padStart(2, '0')}:${time[2]}`, endStopId: Number(terminal?.stop_id), destination: terminal?.name?.trim() || route.name.split(/\s+[-–—]\s+/).at(-1) || route.name, note: typeof trip.note === 'string' ? trip.note.trim() : '', daysOfWeek: days(trip.daysOfWeek), exceptDaysOfWeek: days(trip.exceptDaysOfWeek) }]; }); @@ -67,7 +80,7 @@ export async function loadTimetable({ upstream, cityId, cityName, stop, now }) { while (index < routes.length) { const current = index++, route = routes[current]; const [schedules, stops] = await Promise.all([upstream(`api/v1/routes/${route.id}/schedules`), upstream(`api/v1/routes/${route.id}/stops`)]); - result[current] = { ...route, schedules: normalizeSchedules(schedules, route, stops, Number(stop.id)) }; + result[current] = { ...route, stops: normalizeStops(stops), schedules: normalizeSchedules(schedules, route, stops, Number(stop.id)) }; } })); return { version: 1, cityId, cityName, stop: { id: Number(stop.id), name: stop.name.trim(), direction: stop.description?.trim() || '' }, diff --git a/src/App.tsx b/src/App.tsx index 8fcf0ab..a0227c6 100644 --- a/src/App.tsx +++ b/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 { NumericText } from './NumericText'; import { RouteBadge } from './RouteBadge'; -import { ServiceWindow } from './ServiceWindow'; +import { NextStops } from './NextStops'; import { AlertCard } from './AlertCard'; import { FittedDestination } from './FittedDestination'; import { useIdleControls } from './useIdleControls'; import { PassengerQr } from './PassengerQr'; import { WeatherCard, WeatherSummary } from './WeatherBlock'; import { localDate, 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 type { DisplayDeparture } from './lib/departures'; import logo from './assets/ridebus-logo.svg?raw'; @@ -24,6 +26,8 @@ import { SettingsPanel } from './SettingsPanel'; import { useDeviceControl } from './useDeviceControl'; import type { ManagedState } from './useDeviceControl'; import { DeviceSetup, ManagedPanel } from './DeviceSetup'; +import { nightWindow } from './lib/night'; +import { NightScreen } from './NightScreen'; 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' }); @@ -100,13 +104,14 @@ export function DeparturesScreen({ data, departures, now, alerts, loading, rowCo ; } -function InfoScreen({ data, alerts, fresh, seconds, paused, page, onPage, config, weather, now, settings, service }: { data: BoardData | null; alerts: Alert[]; fresh: boolean; seconds: number; paused: boolean; page: number; onPage: (page: number) => void; - config: Config; weather: ReturnType; now: number; settings: Settings; service: ComponentProps }) { +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; now: number; settings: Settings; journey: Omit, 'active' | 'onPages'>; onJourneyPages: (pages: number) => void }) { const panel = useRef(null); const grid = useRef(null); const [compact, setCompact] = useState(false); const blocks = informationBlocks(settings); 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(() => { 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'); }); @@ -129,7 +134,7 @@ function InfoScreen({ data, alerts, fresh, seconds, paused, page, onPage, config {!alerts.length && blocks.length > 1 && } {alerts.length ?
{alerts.map(alert => )}
:
{blocks.map(block =>
- {block.id === 'service' && } + {block.id === 'service' && } {block.id === 'weather' && } {block.id === 'qr' && }
)} @@ -158,6 +163,7 @@ function BoardApplication({ management }: { management?: { state: ManagedState; const controlsVisible = useIdleControls(settingsOpen); const [scene, setScene] = useState('departures'); const [infoPage, setInfoPage] = useState(0); + const [journeyPages, setJourneyPages] = useState(1); const [paused, setPaused] = useState(false); const [startedAt, setStartedAt] = useState(Date.now()); const [notice, setNotice] = useState(''); @@ -169,6 +175,7 @@ function BoardApplication({ management }: { management?: { state: ManagedState; const date = localDate(now); const fullDepartures = useMemo(() => timetableDepartures(timetable.data, date) as Departure[], [timetable.data, date]); 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 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 || []); @@ -179,17 +186,19 @@ function BoardApplication({ management }: { management?: { state: ManagedState; const statusById = new Map(allDepartures.map(item => [item.id, item])); const departures = rawDepartures.map(item => statusById.get(item.id)!); const first = firstAvailable(departures); - const service = serviceWindow(allDepartures.filter(item => item.status !== 'cancelled'), now, settings.routeFilter); + const 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 held = shouldHoldScene(settings, first, now); const urgent = alerts.some(alert => alert.type === 'cancellation' || alert.type === 'detour'); const hasAlerts = alerts.length > 0; - const scenes = useMemo(() => boardScenes(settings, hasAlerts, urgent), [settings, hasAlerts, urgent]); - const seconds = scene === 'departures' ? settings.mainSeconds : scene === 'info' ? Math.max(settings.infoSeconds, Math.min(60, alerts.reduce((sum, alert) => sum + alert.message.length, 0) / 18)) : settings.adSeconds; + const 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, !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 progress = Math.min(1, elapsed / (seconds * 1000)); - const sceneState = useRef({ scenes, scene, held, settingsOpen, paused, calm: reading.calm }); - sceneState.current = { 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 || night.active }; const showNotice = useCallback((text: string) => { setNotice(text); if (noticeTimer.current) clearTimeout(noticeTimer.current); noticeTimer.current = setTimeout(() => setNotice(''), 4000); @@ -237,7 +246,7 @@ function BoardApplication({ management }: { management?: { state: ManagedState; if (held) { setScene('departures'); setStartedAt(Date.now()); } else setStartedAt(Date.now()); }, [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(() => { if (paused || settingsOpen) { setStartedAt(Date.now()); return; } 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 updateLabel = fullAvailable && timetable.data ? timeFormat.format(Date.parse(timetable.data.updatedAt)) : data ? timeFormat.format(Date.parse(data.updatedAt)) : '—'; const direction = stopDirection(data?.stop); - return
+ const theme = night.active ? 'dark' : settings.theme; + return

{data?.stop.name || config.stopName}

{direction &&
{direction}
}
-
{dateFormat.format(now)}
+
{!night.active && }
{dateFormat.format(now)}
{!fresh && !settings.demo &&
{data ? `Нет свежих данных. Сохранённое расписание · обновлено в ${updateLabel}` : loading ? 'Подключаемся к RideBus…' : 'Не удалось загрузить расписание. Проверьте подключение.'}
} {management?.failed &&
Нет связи с управлением · сохранённые настройки
}
- {scene === 'departures' && } - {scene === 'info' && } - {scene === 'ad' &&
РЕКЛАМА · ПРЕДПРОСМОТР

{settings.adTitle}

{settings.adText}

Время для нового.
} + {night.active && night.first && } + {!night.active && scene === 'departures' && } + {!night.active && scene === 'info' && } + {!night.active && scene === 'ad' &&
РЕКЛАМА · ПРЕДПРОСМОТР

{settings.adTitle}

{settings.adText}

Время для нового.
}
- {scene !== 'departures' &&
БЛИЖАЙШИЙ РЕЙС{first ? <>
{first.destination}
: Нет доступных рейсов}
} + {!night.active && scene !== 'departures' &&
БЛИЖАЙШИЙ РЕЙС{first ? <>
{first.destination}
: Нет доступных рейсов}
}
-
+
{!fresh && !settings.demo && }{settings.demo ? 'Демонстрация' : held ? 'Приоритет транспорта' : `Обновлено в ${updateLabel}`}{data?.partial ? ' · часть рейсов недоступна' : ''}{offlineSaved === false ? ' · сохранение недоступно' : ''}
diff --git a/src/NextStops.tsx b/src/NextStops.tsx new file mode 100644 index 0000000..309f819 --- /dev/null +++ b/src/NextStops.tsx @@ -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 <>

Дальше по маршруту

+ {!departure ?

{loading ? 'Получаем расписание' : 'Нет доступного рейса'}

: <> +
+ {!journey ?

{loading ? 'Загружаем остановки' : 'Схема маршрута пока недоступна'}

: !count ?

Здесь конечная этого рейса

: <> +
0} data-loop={page > 0 && focus === 0}> +
{settings.demo ? 'Демонстрация поездки' : 'После посадки'}{focus + 1}-я остановка
+
    + {journey.stops.map((stop, index) =>
  1. + + {stop.minutes === null ? '—' : stop.minutes === 0 ? <><1мин : durationParts(stop.minutes).map(part => {part.unit})} + +
    +
  2. )} + {journey.more > 0 &&
  3. Ещё {journey.more} остановок
  4. } +
+
+
Время пути по расписанию{pages > 1 && {focus + 1} / {count}}
+ } + } + ; +} diff --git a/src/NightScreen.tsx b/src/NightScreen.tsx new file mode 100644 index 0000000..f3d68c4 --- /dev/null +++ b/src/NightScreen.tsx @@ -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(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; + 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
{alerts.length > 1 && {page % alerts.length + 1} / {alerts.length}}
; +} + +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
0} aria-label="Ночной режим"> +
Ночной режим

{completedToday ? routeFiltered ? 'Рейсы маршрута на сегодня завершены' : 'Движение на сегодня завершено' : 'Доброй ночи'}

+
Первый рейс {tomorrow ? 'завтра' : 'сегодня'}

{first.destination}

+
+ {alerts.length > 0 && } +
; +} diff --git a/src/ServiceWindow.tsx b/src/ServiceWindow.tsx deleted file mode 100644 index 764293c..0000000 --- a/src/ServiceWindow.tsx +++ /dev/null @@ -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 <>

Первый и последний рейс

- {available ?
- {[ - { label: 'Последний сегодня', departure: lastToday, empty: 'Сегодня рейсов нет', ended: Boolean(lastToday && Date.parse(lastToday.scheduledAt) + 60000 <= now) }, - { label: 'Первый завтра', departure: firstTomorrow, empty: tomorrowAvailable ? 'Завтра рейсов нет' : 'Расписание на завтра недоступно', ended: false }, - ].map(item =>
-
{item.label}{item.ended && время прошло}
- {item.departure ? <> -
{item.departure.destination}
- :

{item.empty}

} -
)} -
:

{loading ? 'Загружаем полное расписание' : 'Полное расписание пока недоступно'}

} - ; -} diff --git a/src/SettingsPanel.tsx b/src/SettingsPanel.tsx index f5bd09a..7edda73 100644 --- a/src/SettingsPanel.tsx +++ b/src/SettingsPanel.tsx @@ -96,6 +96,8 @@ export function SettingsPanel({ settings, config, data, onSave, onClose, onFulls +

Ночной режим

+

Во время ночного перерыва длиннее двух часов. Обычный экран вернётся за выбранное время до первого рейса. Работает по полному расписанию; ночью остаются первый рейс, часы и уведомления.

Блоки информации

{informationOptions.map(block => )} {!informationBlocks(draft).length &&

Экран информации появится только при предупреждениях о движении.

} @@ -108,7 +110,7 @@ export function SettingsPanel({ settings, config, data, onSave, onClose, onFulls

{navigation.editing ? 'Стрелки — изменить · OK — готово' : navigation.remote ? 'Стрелки — переход · OK — выбрать' : 'Наведите курсор и нажмите OK'}

-
+
; } diff --git a/src/lib/demo.ts b/src/lib/demo.ts index d9ece2c..cc9b92c 100644 --- a/src/lib/demo.ts +++ b/src/lib/demo.ts @@ -1,5 +1,12 @@ import type { BoardData } from '../types'; import { minskDate } from './board.mjs'; +import type { Departure } from '../types'; +import type { OnwardJourney } from './journey'; + +export function demoJourney(departure: Departure | undefined): OnwardJourney | null { + if (!departure) return null; + return { stops: ['Площадь Свободы', 'Дом торговли', 'Поликлиника', departure.destination].map((name, index) => ({ id: 1000 + index, name, minutes: [3, 6, 9, 18][index] })), more: 0 }; +} export function createDemo(now = Date.now()): BoardData { const routes = [ diff --git a/src/lib/device.mjs b/src/lib/device.mjs index 4d0d45f..3f426df 100644 --- a/src/lib/device.mjs +++ b/src/lib/device.mjs @@ -1,11 +1,17 @@ import { colorSchemes, lightColorSchemes } from './presentation.mjs'; export const MANAGED_KEY = 'ridebus-board-managed-v1'; +export function hydrateManagedState(value) { + if (value?.state !== 'paired' || !value.device?.settings || typeof value.device.settings !== 'object') return value; + return { ...value, device: { ...value.device, settings: { nightMode: 'auto', nightWakeMinutes: 60, ...value.device.settings } } }; +} export function isManagedState(value) { + value = hydrateManagedState(value); const device = value?.device, s = device?.settings, config = value?.config; if (value?.state !== 'paired' || !device || !s || !config || typeof device.id !== 'string' || typeof device.name !== 'string' || !Number.isInteger(device.revision) || device.revision < 1 || !Number.isInteger(device.cityId) || device.cityId < 1 || config.cityId !== device.cityId || config.defaultStopId !== s.stopId) return false; if (typeof config.configured !== 'boolean' || !Number.isInteger(config.refreshSeconds) || config.refreshSeconds < 5 || config.refreshSeconds > 120 || config.timeZone !== 'Europe/Minsk') return false; for (const [key, min, max] of [['stopId', 1, 1000000], ['mainSeconds', 5, 120], ['infoSeconds', 5, 60], ['rowCount', 2, 6], ['adSeconds', 5, 30]]) if (!Number.isInteger(s[key]) || s[key] < min || s[key] > max) return false; if (s.demo !== false || !['dark', 'light'].includes(s.theme) || !['tv', 'street'].includes(s.displayProfile) || !['near', 'medium', 'far'].includes(s.viewingDistance)) return false; + if (!['off', 'auto'].includes(s.nightMode) || ![45, 60].includes(s.nightWakeMinutes)) return false; if (!colorSchemes.some(color => color.id === s.colorScheme) || !lightColorSchemes.some(color => color.id === s.lightColorScheme)) return false; for (const key of ['holdNear', 'infoServiceEnabled', 'infoWeatherEnabled', 'infoQrEnabled', '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'; diff --git a/src/lib/information.ts b/src/lib/information.ts index 1215ec6..64cb57c 100644 --- a/src/lib/information.ts +++ b/src/lib/information.ts @@ -1,8 +1,7 @@ -import type { Departure, Scene, Settings } from '../types'; -import { localDate, shiftDate } from './timetable.mjs'; +import type { Scene, Settings } from '../types'; export const informationOptions = [ - { id: 'service', setting: 'infoServiceEnabled', name: 'Движение', label: 'Первый и последний рейс' }, + { id: 'service', setting: 'infoServiceEnabled', name: 'Маршрут', label: 'Дальше по маршруту' }, { id: 'weather', setting: 'infoWeatherEnabled', name: 'Погода', label: 'Погода' }, { id: 'qr', setting: 'infoQrEnabled', name: 'RideBus', label: 'QR-код расписания' }, ] as const; @@ -15,20 +14,3 @@ export function boardScenes(settings: Settings, hasAlerts: boolean, urgent: bool return ['departures', ...(hasAlerts || informationBlocks(settings).length ? ['info'] : []), ...(settings.adsEnabled && !urgent ? ['ad'] : [])] as Scene[]; } - -// Use the complete timetable, including arrivals carried over from the previous -// service day. A short live arrivals response cannot establish the last trip. -export function serviceWindow(departures: Departure[], now: number, routeFilter: string) { - const today = localDate(now); - const todayStart = Date.parse(`${today}T00:00:00+03:00`); - const tomorrowStart = Date.parse(`${shiftDate(today, 1)}T00:00:00+03:00`); - const tomorrowEnd = tomorrowStart + 86400000; - let lastToday: Departure | null = null, firstTomorrow: Departure | null = null; - for (const departure of departures) { - if (routeFilter && String(departure.routeId) !== routeFilter) continue; - const at = Date.parse(departure.scheduledAt); - if (at >= todayStart && at < tomorrowStart && (!lastToday || at > Date.parse(lastToday.scheduledAt))) lastToday = departure; - if (at >= tomorrowStart && at < tomorrowEnd && (!firstTomorrow || at < Date.parse(firstTomorrow.scheduledAt))) firstTomorrow = departure; - } - return { lastToday, firstTomorrow }; -} diff --git a/src/lib/journey.ts b/src/lib/journey.ts new file mode 100644 index 0000000..2c24c24 --- /dev/null +++ b/src/lib/journey.ts @@ -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) }; +} diff --git a/src/lib/night.ts b/src/lib/night.ts new file mode 100644 index 0000000..83682cc --- /dev/null +++ b/src/lib/night.ts @@ -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 }; +} diff --git a/src/lib/storage.ts b/src/lib/storage.ts index 4401f45..cf5610e 100644 --- a/src/lib/storage.ts +++ b/src/lib/storage.ts @@ -10,6 +10,7 @@ export const defaultSettings: Settings = { stopId: 1, demo: false, mainSeconds: rowCount: 4, routeFilter: '', theme: 'dark', colorScheme: 'lime', lightColorScheme: 'blue', infoServiceEnabled: true, infoWeatherEnabled: true, infoQrEnabled: true, showNextSameRoute: true, longWaitEnabled: true, displayProfile: 'tv', viewingDistance: 'near', + nightMode: 'auto', nightWakeMinutes: 60, adsEnabled: false, adSeconds: 7, adTitle: 'Ваше место в городе', adText: 'Короткая история. Большая аудитория.' }; export function readSettings(): Settings { try { @@ -22,6 +23,7 @@ export function readSettings(): Settings { infoServiceEnabled: saved.infoServiceEnabled !== false, infoWeatherEnabled: saved.infoWeatherEnabled !== false, infoQrEnabled: saved.infoQrEnabled !== false, showNextSameRoute: saved.showNextSameRoute !== false, longWaitEnabled: saved.longWaitEnabled !== false, displayProfile: saved.displayProfile === 'street' ? 'street' : 'tv', viewingDistance: ['medium', 'far'].includes(saved.viewingDistance) ? saved.viewingDistance : 'near', + nightMode: saved.nightMode === 'off' ? 'off' : 'auto', nightWakeMinutes: saved.nightWakeMinutes === 45 ? 45 : 60, adsEnabled: saved.adsEnabled === true, adSeconds: bound(saved.adSeconds, 7, 5, 30), adTitle: typeof saved.adTitle === 'string' ? saved.adTitle.slice(0, 80) : defaultSettings.adTitle, adText: typeof saved.adText === 'string' ? saved.adText.slice(0, 200) : defaultSettings.adText }; diff --git a/src/lib/timetable.mjs b/src/lib/timetable.mjs index 1a33277..2046822 100644 --- a/src/lib/timetable.mjs +++ b/src/lib/timetable.mjs @@ -12,12 +12,17 @@ export function isTimetable(value, cityId, stopId) { && Array.isArray(value.calendarYears) && value.calendarYears.every(Number.isInteger) && Array.isArray(value.holidays) && value.holidays.every(validDate) && Array.isArray(value.routes) && value.routes.every(route => Number.isSafeInteger(route.id) && route.id > 0 && typeof route.number === 'string' + && (route.stops === undefined || Array.isArray(route.stops) && route.stops.every(stop => Number.isSafeInteger(stop.id) && stop.id > 0 + && typeof stop.name === 'string' && stop.name.trim() && Number.isSafeInteger(stop.sequence) && stop.sequence >= 0 + && (stop.offsetMinutes === null || typeof stop.offsetMinutes === 'number' && Number.isFinite(stop.offsetMinutes) && stop.offsetMinutes >= 0 && stop.offsetMinutes < 1440)) + && new Set(route.stops.map(stop => stop.id)).size === route.stops.length && new Set(route.stops.map(stop => stop.sequence)).size === route.stops.length) && typeof route.name === 'string' && Array.isArray(route.schedules) && route.schedules.every(schedule => ['weekday', 'saturday', 'sunday', 'holiday'].includes(schedule.dayType) && (!schedule.validFrom || validDate(schedule.validFrom)) && (!schedule.validUntil || validDate(schedule.validUntil)) && Array.isArray(schedule.trips) && schedule.trips.every(trip => Number.isFinite(trip.arrivalSeconds) && trip.arrivalSeconds >= 0 && trip.arrivalSeconds < 172800 && typeof trip.destination === 'string' && typeof trip.note === 'string' && Array.isArray(trip.daysOfWeek) && (trip.originStopId == null || Number.isSafeInteger(trip.originStopId) && trip.originStopId > 0) + && (trip.endStopId == null || Number.isSafeInteger(trip.endStopId) && trip.endStopId > 0) && (trip.originTime == null || typeof trip.originTime === 'string' && /^([01]\d|2[0-3]):[0-5]\d$/.test(trip.originTime)) && Array.isArray(trip.exceptDaysOfWeek) && [...trip.daysOfWeek, ...trip.exceptDaysOfWeek].every(day => Number.isInteger(day) && day >= 1 && day <= 7))))); } @@ -45,7 +50,7 @@ export function timetableDepartures(bundle, date) { if (arrivalDate < date || arrivalDate > shiftDate(date, 1)) continue; result.push({ id: `${route.id}:${serviceDate}:${index}:${scheduledAt}`, routeId: route.id, routeNumber: route.number, routeName: route.name, destination: trip.destination, type: route.type, scheduledAt, - serviceDate, originStopId: trip.originStopId, originTime: trip.originTime, + 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' }); } } diff --git a/src/styles.css b/src/styles.css index 16d8cf6..a9d0de2 100644 --- a/src/styles.css +++ b/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-list .destination-heading { width: 100%; font-size: clamp(22px, 3cqw, 30px); line-height: 1.2; margin: 0; font-weight: 550; letter-spacing: -.025em; overflow-wrap: anywhere; } .route-list-pages { display: flex; justify-content: flex-end; color: var(--muted); font-size: 16px; padding-top: 10px; } -.service-info { container-name: service-card; } -.service-window { flex: 1 1 0; min-height: 0; display: grid; grid-template-rows: repeat(2, minmax(0, 1fr)); gap: 20px; } -.service-boundary { min-width: 0; min-height: 0; display: flex; flex-direction: column; justify-content: center; gap: 12px; } -.service-boundary + .service-boundary { border-top: 1px solid var(--line); padding-top: 20px; } -.service-label { display: flex; flex-wrap: wrap; gap: 4px 10px; color: var(--secondary); font-size: clamp(17px, 3cqw, 24px); line-height: 1.25; } -.service-label span { color: var(--muted); font-size: .8em; align-self: center; } -.service-boundary > time { display: block; color: var(--accent); font-size: clamp(44px, min(17cqw, 19cqh), 112px); font-weight: 600; line-height: 1; } -.service-trip { min-width: 0; display: flex; align-items: center; gap: 12px; font-size: clamp(18px, 4cqw, 26px); line-height: 1.2; } -.service-trip .route-badge { flex-shrink: 0; width: 72px; height: 48px; border-radius: 14px; font-size: 32px; padding: 0 8px; } -.service-trip > span:last-child { overflow-wrap: anywhere; } -.service-info .service-empty { margin: 0; font-size: 20px; } -.service-info .service-unavailable { margin-block: auto; text-align: center; font-size: 22px; } -@container service-card (min-width: 600px) { - .service-window { grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-rows: minmax(0, 1fr); gap: 32px; } - .service-boundary + .service-boundary { border-top: 0; border-left: 1px solid var(--line); padding-top: 0; padding-left: 32px; } +/* A moving line and one large stop, inspired by over-door transit displays. */ +.service-info { container-name: journey-card; overflow: hidden; } +.journey-route { display: flex; align-items: center; gap: 18px; min-width: 0; padding-bottom: 18px; border-bottom: 1px solid var(--line); } +.journey-route .route-badge { flex-shrink: 0; width: 80px; height: 62px; border-radius: 18px; font-size: 36px; } +.journey-trip { min-width: 0; flex: 1; display: flex; align-items: center; gap: 18px; } +.journey-route-name { min-width: 0; flex: 1; } +.service-info .journey-trip .destination-heading { margin: 0; font-size: clamp(22px, 3.8cqw, 36px); line-height: 1.2; overflow-wrap: anywhere; } +.journey-trip time { display: flex; gap: 8px; align-items: baseline; color: var(--accent); font-size: clamp(22px, 4.5cqw, 40px); font-weight: 600; } +.journey-trip .journey-day { color: var(--secondary); font-size: 17px; font-weight: 400; } +.service-info .journey-empty { margin-block: auto; padding: 20px 0; text-align: center; font-size: 22px; } +.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); } +.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; } +.journey-order { color: var(--accent); font-size: .85em; white-space: nowrap; } +.journey-map-space { flex: 1 1 0; min-height: 0; display: flex; align-items: center; margin: 18px -8px 0; } +.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) { - .service-window { gap: 12px; } - .service-boundary { gap: 8px; } - .service-boundary + .service-boundary { padding-top: 12px; } - .service-label { font-size: 17px; } - .service-boundary > time { font-size: clamp(40px, min(14cqw, 25cqh), 72px); } - .service-trip { font-size: 18px; } - .service-trip .route-badge { width: 60px; height: 40px; font-size: 28px; border-radius: 12px; } +@container journey-card (max-height: 380px) { + .journey-route { gap: 12px; padding-bottom: 12px; } + .journey-route .route-badge { width: 72px; height: 48px; border-radius: 14px; font-size: 30px; } + .service-info .journey-trip .destination-heading { font-size: 24px; } + .journey-trip time { font-size: 24px; } + .journey-metro { padding-top: 12px; } + .journey-map-space { margin-top: 10px; } + .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) { - .service-boundary + .service-boundary { padding-top: 0; padding-left: 20px; } +@media (prefers-reduced-motion: reduce) { + .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-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-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}} diff --git a/src/types.ts b/src/types.ts index 05ea109..0fa50fd 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,9 +1,10 @@ export type Departure = { id: string; routeId: number; routeNumber: string; routeName: string; destination: string; type: string; scheduledAt: string; scheduledTime: string; note: string; source: 'schedule'; - serviceDate?: string; originStopId?: number; originTime?: string; + serviceDate?: string; originStopId?: number; originTime?: string; endStopId?: number; }; 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 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 }; @@ -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; infoServiceEnabled: boolean; infoWeatherEnabled: boolean; infoQrEnabled: boolean; 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 }; export type Scene = 'departures' | 'info' | 'ad'; -export type Timetable = { version: 1; cityId: number; cityName: string; stop: Stop; routes: (Route & { schedules: { - dayType: string; validFrom: string | null; validUntil: string | null; trips: { arrivalSeconds: number; destination: string; note: string; daysOfWeek: number[]; exceptDaysOfWeek: number[]; originStopId?: number; originTime?: string }[]; +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; endStopId?: number }[]; }[] })[]; holidays: string[]; calendarYears: number[]; updatedAt: string; expiresAt: string }; export type Weather = { cityId: number; stopId: number; updatedAt: string; observedAt: string; temperature: number; apparentTemperature: number; code: number; isDay: boolean; windSpeed: number; hourly: { at: string; temperature: number; code: number; precipitationProbability: number }[] }; diff --git a/src/useDeviceControl.ts b/src/useDeviceControl.ts index a79999f..bcf3f72 100644 --- a/src/useDeviceControl.ts +++ b/src/useDeviceControl.ts @@ -1,9 +1,9 @@ import { useCallback, useEffect, useRef, useState } from 'react'; 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 } }; 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' }); export function useDeviceControl() { const [managed, setManaged] = useState(cached), [control, setControl] = useState({ 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 (value.state === 'paired' && !value.config) value = await call('status'); if (value.state === 'paired') { + value = hydrateManagedState(value); if (!isManagedState(value)) throw new Error('invalid_settings'); if (!stopped) { setManaged(previous => previous && previous.device.id === value.device.id && previous.device.revision === value.device.revision ? previous : value); setControl(value); setFailed(false); } if (Date.now() - lastHeartbeat >= 30000) { await call('heartbeat', { ...info(), appliedRevision: current.current?.device.id === value.device.id ? applied.current : 0 }); lastHeartbeat = Date.now(); } diff --git a/test/device.test.mjs b/test/device.test.mjs index 7f5b413..c69d032 100644 --- a/test/device.test.mjs +++ b/test/device.test.mjs @@ -1,7 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; -import { isManagedState } from '../src/lib/device.mjs'; +import { hydrateManagedState, isManagedState } from '../src/lib/device.mjs'; import { createDeviceBridge } from '../server/device.mjs'; import { createApp } from '../server/index.mjs'; const source = await readFile(new URL('../src/lib/storage.ts', import.meta.url), 'utf8'); @@ -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); assert.equal(isManagedState({ ...value, config: { ...value.config, refreshSeconds: 0 } }), 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 => { const tokenA = 'A'.repeat(43), tokenB = 'B'.repeat(43), calls = []; diff --git a/test/fixtures/night-server.mjs b/test/fixtures/night-server.mjs new file mode 100644 index 0000000..61cd9ab --- /dev/null +++ b/test/fixtures/night-server.mjs @@ -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(`

Тестовые сценарии ночного режима

Текущий: ${scenario}; уведомление: ${alertsOn}; истёкший кеш: ${expired}

${Object.keys(scenarios).map(s=>``).join('')}

Часы приложения обновятся при опросе (5 секунд). После смены полного расписания обновите страницу табло.

`)); +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'); diff --git a/test/information.test.mjs b/test/information.test.mjs index 1653d37..7be0e46 100644 --- a/test/information.test.mjs +++ b/test/information.test.mjs @@ -1,10 +1,9 @@ import test from 'node:test'; 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'; const off = { ...defaultSettings, infoServiceEnabled: false, infoWeatherEnabled: false, infoQrEnabled: false }; -const trip = (routeId, at) => ({ routeId, routeNumber: String(routeId), scheduledAt: at }); test('disabled information blocks are omitted without leaving gaps in the cycle', () => { assert.deepEqual(informationBlocks(defaultSettings).map(block => block.id), ['service', 'weather', 'qr']); @@ -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, 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 }); -}); diff --git a/test/journey.test.mjs b/test/journey.test.mjs new file mode 100644 index 0000000..1fe3629 --- /dev/null +++ b/test/journey.test.mjs @@ -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); +}); diff --git a/test/night.test.mjs b/test/night.test.mjs new file mode 100644 index 0000000..1ef4787 --- /dev/null +++ b/test/night.test.mjs @@ -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'); +}); diff --git a/test/storage.test.mjs b/test/storage.test.mjs index f86d699..c228fdb 100644 --- a/test/storage.test.mjs +++ b/test/storage.test.mjs @@ -2,6 +2,13 @@ import test from 'node:test'; import assert from 'node:assert/strict'; 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: [], departures: [{ id: '1:12:30', scheduledAt: '2026-10-08T12:30:00+03:00' }] };