diff --git a/README.md b/README.md
index 589a3ec..d6aa62e 100644
--- a/README.md
+++ b/README.md
@@ -25,8 +25,13 @@ npm start
- Автоматический ночной режим включается после завершения движения по полному расписанию, если весь ночной перерыв до первого следующего рейса длиннее двух часов. На чёрном фоне — первый рейс, направление, часы без секунд и актуальные уведомления. Информация и реклама не сменяют этот экран. При оставшихся 60 минутах возвращается обычное табло; в настройках → «Экраны» можно выбрать 45 минут или выключить режим. Дневная тема и палитра восстанавливаются автоматически. По умолчанию режим автоматический, возврат за 60 минут.
- Настройки → «Оформление» → «Читаемость»: профили «Телевизор» / «Уличное табло» и расстояние «Близко» / «Средне» / «Далеко». Для улицы и дальнего просмотра текст крупнее, одновременно видимых строк меньше. Уличный профиль использует спокойное короткое появление вместо вертикальной прокрутки текста. Информационные карточки при дальнем просмотре показываются по одной.
- В шапке — название и направление конкретной площадки из API, часы и дата в `Europe/Minsk`. Поле «основная» не выдаётся за направление; если направления нет, оно не придумывается. Город, подпись «Остановка», «RideBus API» и «Все направления» убраны из шапки.
-- Информационный экран: «Дальше по маршруту», погода, QR-код и предупреждения. Схема показывает до четырёх следующих остановок ближайшего доступного рейса и время пути от текущей остановки по расписанию. Учитывает выбранный маршрут, отмены, укороченные и обратные рейсы; на конечной не придумывает продолжение. Неизвестное время отмечено «—». Горизонтальная линия плавно сдвигается влево, выделенное название остановки увеличивается, соседние остаются компактными — по мотивам [инфосистемы «Квант»](https://www.artlebedev.ru/kvant/moscow/). Это обзор поездки после посадки, а не положение автобуса: остановки сменяются каждые 4 секунды (6 для уличного профиля), сцена автоматически удлиняется до полного обзора. В узкой карточке название и схема подстраиваются под доступное место. Часы и ближайший рейс остаются видимыми. В вертикальной и короткой компоновке карточки сменяются при следующих посещениях экрана; их также можно выбрать кнопками «Маршрут», «Погода», «RideBus».
-- В настройках → «Экраны» → «Блоки информации» каждый из трёх блоков можно включить или отключить. Оставшиеся карточки занимают доступную ширину, отключённые пропускаются в компактном цикле. При отключении всех блоков информационный экран исключается из обычного цикла; активные предупреждения о движении всё равно показываются. Выбор сохраняется в браузере; «Сбросить цикл» включает все блоки.
+- Информационный экран: «Дальше по ближайшему рейсу», погода, QR-код и предупреждения. Схема показывает до четырёх следующих остановок ближайшего доступного рейса и время пути от текущей остановки по расписанию. Учитывает выбранный маршрут, отмены, укороченные и обратные рейсы; на конечной не придумывает продолжение. Неизвестное время отмечено «—». Горизонтальная линия плавно сдвигается влево, выделенное название остановки увеличивается, соседние остаются компактными — по мотивам [инфосистемы «Квант»](https://www.artlebedev.ru/kvant/moscow/). Это обзор поездки после посадки, а не положение автобуса: остановки сменяются каждые 4 секунды (6 для уличного профиля), сцена автоматически удлиняется до полного обзора. В узкой карточке название и схема подстраиваются под доступное место. Часы остаются в шапке, ближайший рейс показан в нижней панели; карточка не дублирует номер, направление и время отправления. В вертикальной и короткой компоновке карточки сменяются при следующих посещениях экрана; их также можно выбрать кнопками «Маршрут», «Погода», «RideBus».
+- Настройки → «Экраны» → «Транслит названий остановок»: дополнительная строка латиницей над основным названием в схеме ближайшего рейса. Например, «Пушкинская» → «Pushkinskaya». По умолчанию выключен; сохраняется после перезагрузки. Это транслитерация русских и белорусских названий, без перевода. Работает в обеих темах и профилях экрана.
+- «До важных мест»: до трёх заданных остановок назначения. Для каждой — ближайший прямой рейс, отправление, время в пути и прибытие по полному расписанию. Учитываются направление, конечная конкретного рейса, отмены и переход через полночь. При неизвестных временных смещениях показывается только отправление; без актуального полного расписания с порядком остановок карточка скрыта. Реальные места по умолчанию не выбраны. Их можно задать в настройках → «Экраны» или в админке устройства. На коротком экране места сменяются страницами каждые 7 секунд (10 для улицы); сцена удлиняется, чтобы показать все страницы.
+- Напоминание о последнем рейсе появляется за 30 минут до последнего отправления данного маршрута в данном направлении перед ночным перерывом. Расчёт использует ту же политику ночного перерыва, включая рейсы после полуночи, и полный действующий календарь. Это расписание, а не обещание фактического движения. Напоминание видно и на главном экране, и в нижней панели информации; его можно отключить.
+- Погодная подсказка — вероятность осадков в ближайший час из почасового прогноза Open-Meteo. Нулевая вероятность сохраняется; при отсутствии ближайшего часа или устаревшем прогнозе подсказка скрыта. Переключатель — «Вероятность осадков» в настройках экранов.
+- «Оплата поездки»: крупная цена и способы оплаты для ближайшего доступного маршрута, с датой подтверждения. По данным пользователя от 11 октября 2026 года для Полоцка: автобус **1,05 BYN**, маршрутка **2,10 BYN**, оплата **QR-кодом или наличными**. Основные тарифы привязаны к городу и типу транспорта; для других городов не подставляются. Можно задать исключения для конкретных маршрутов. Явно удалённые тарифы остаются удалёнными; без подходящего подтверждённого тарифа карточка скрыта. QR-код RideBus открывает расписание; платёжный код не генерируется.
+- В настройках → «Экраны» → «Блоки информации» каждый блок можно включить или отключить. Отключённые и незаполненные карточки пропускаются; при четырёх и более доступных блоках они показываются по одной, чтобы сохранить читаемость. При отключении всех блоков информационный экран исключается из обычного цикла; активные предупреждения о движении всё равно показываются. Выбор сохраняется в браузере; «Сбросить цикл» включает три базовых блока, сохраняя пассажирские настройки.
- Уведомления об отменах, объездах, задержках и другие сообщения оформлены простой карточкой с иконкой, номерами маршрутов и сроком действия. Исходные заголовки, тексты и детали отмен сохраняются. Статус предупреждений отображается только при их недоступности.
- Цикл 25 секунд / 8 секунд, настраиваемый. До ближайшего рейса ≤2 минут главный экран удерживается; можно отключить в настройках. Отмены и объезды исключают рекламную сцену из цикла.
- Опциональная рекламная сцена с текстом и длительностью; по умолчанию выключена, помечена как предпросмотр. Загрузка медиа и кампании в MVP не входят.
@@ -107,8 +112,12 @@ docker compose up --build -d
Если API записывает ранний рейс 00:xx в новый календарный день, ночная логика дополнительно проверяет короткое продолжение предыдущего блока движения и первый длинный перерыв после него. Это позволяет включить режим после реального рейса 00:40 перед первым утренним в 05:44, не скрывая непрерывное ночное движение или дневные паузы. Даты рейсов и условия календаря при этом не изменяются.
-Схема маршрута использует порядок и временные смещения остановок из полного кеша (`routes[].stops`) и конечную конкретного рейса (`endStopId`). Метаданные загружаются вместе с расписаниями, дополнительных запросов при смене рейса нет. Старые кеши без этих полей остаются пригодны для табло, но схема появляется после обновления полного расписания. Переключатель «Дальше по маршруту» сохраняет прежний ключ `infoServiceEnabled`, поэтому существующие настройки локального и удалённого управления совместимы; миграция БД не требуется.
+Схема маршрута использует порядок и временные смещения остановок из полного кеша (`routes[].stops`) и конечную конкретного рейса (`endStopId`). Метаданные загружаются вместе с расписаниями, дополнительных запросов при смене рейса нет. Старые кеши без этих полей остаются пригодны для табло, но схема появляется после обновления полного расписания. Переключатель «Дальше по ближайшему рейсу» сохраняет прежний ключ `infoServiceEnabled`, поэтому существующие настройки локального и удалённого управления совместимы; миграция БД не требуется. Транслит управляется флагом `journeyTranslitEnabled`; в старой конфигурации он считается выключенным. Для удалённого переключения обновите табло, backend и frontend `polotsk-transit-api`: опция находится в настройках устройства, в блоке информации.
В обоих профилях схема анимирует только `transform` и `opacity`: ячейки имеют постоянную ширину, крупный и компактный варианты названия подбираются заранее и не пересоздаются при каждом переходе. В уличном профиле движение спокойнее. Схема не перерисовывается из-за тиков часов, если её данные не изменились; на конечной линия заканчивается у последнего настоящего маркера.
Для локального сравнения работы подбора шрифтов можно запустить `JOURNEY_PERF=1 node test/fixtures/night-server.mjs`. Параметр `BOARD_FIXTURE_DIST` позволяет использовать сохранённую старую сборку. Наблюдатели пишут `JOURNEY_PERF` в консоль: изменения размеров текстовых блоков, записи размера шрифта и замены заголовков за 1200 мс после смены остановки. Это диагностический стенд, а не замер FPS устройства; он не включён в рабочее табло и Docker runtime.
+
+Пассажирские настройки устройства: `infoPlacesEnabled`, `infoFareEnabled`, `lastTripNoticeEnabled`, `weatherHintEnabled` (по умолчанию `true`); `places: [{stopId, name}]` (до 3); `fareByType: [{cityId, type, price, payment, checkedAt}]` (до 2, `type: bus | minibus`); `fareRules: [{routeId, price, payment, checkedAt}]` (до 32). Дата `checkedAt` — действительная дата `YYYY-MM-DD`, будущая дата не публикует тариф раньше срока. Сервер проверяет принадлежность остановок городу, маршрутов — площадке, основных тарифов — городу устройства. Старые конфигурации получают совместимые значения; миграция SQL не нужна. Для удалённого управления обновите backend/frontend `polotsk-transit-api` и Board вместе.
+
+Локальный стенд для этих блоков: `node test/fixtures/passenger-server.mjs`, затем `http://127.0.0.1:5194/__fixture`. Кнопки выбирают ближайший тип транспорта и открывают табло с явными тестовыми остановками, расписанием и погодой. Стенд использует отдельный origin, не обращается к рабочей БД и не изменяет настройки основного табло.
diff --git a/artifacts/journey-translit-960x540.png b/artifacts/journey-translit-960x540.png
new file mode 100644
index 0000000..72a61ea
Binary files /dev/null and b/artifacts/journey-translit-960x540.png differ
diff --git a/artifacts/journey-translit-street-light-960x540.png b/artifacts/journey-translit-street-light-960x540.png
new file mode 100644
index 0000000..c9e2e3e
Binary files /dev/null and b/artifacts/journey-translit-street-light-960x540.png differ
diff --git a/artifacts/passenger-admin-fields.png b/artifacts/passenger-admin-fields.png
new file mode 100644
index 0000000..f849247
Binary files /dev/null and b/artifacts/passenger-admin-fields.png differ
diff --git a/artifacts/passenger-fare-minibus-960x540.jpg b/artifacts/passenger-fare-minibus-960x540.jpg
new file mode 100644
index 0000000..25e5ad2
Binary files /dev/null and b/artifacts/passenger-fare-minibus-960x540.jpg differ
diff --git a/artifacts/passenger-places-390x844.png b/artifacts/passenger-places-390x844.png
new file mode 100644
index 0000000..9b0feea
Binary files /dev/null and b/artifacts/passenger-places-390x844.png differ
diff --git a/artifacts/passenger-places-960x540.png b/artifacts/passenger-places-960x540.png
new file mode 100644
index 0000000..88a0d81
Binary files /dev/null and b/artifacts/passenger-places-960x540.png differ
diff --git a/artifacts/passenger-weather-960x540.png b/artifacts/passenger-weather-960x540.png
new file mode 100644
index 0000000..f17dba8
Binary files /dev/null and b/artifacts/passenger-weather-960x540.png differ
diff --git a/artifacts/passenger-weather-street-light-960x540.png b/artifacts/passenger-weather-street-light-960x540.png
new file mode 100644
index 0000000..cd5aa29
Binary files /dev/null and b/artifacts/passenger-weather-street-light-960x540.png differ
diff --git a/artifacts/verification-journey-transliteration.md b/artifacts/verification-journey-transliteration.md
new file mode 100644
index 0000000..8e1365f
--- /dev/null
+++ b/artifacts/verification-journey-transliteration.md
@@ -0,0 +1,30 @@
+# Схема ближайшего рейса и транслит
+
+Проверено 11 октября 2026 года.
+
+## Изменения
+
+- Убраны повторные номер, направление и время отправления из карточки схемы. Ближайший рейс остаётся в постоянной нижней панели.
+- Карточка и её переключатель называются «Дальше по ближайшему рейсу».
+- Убран заголовок «Информация» и занимаемый им отступ. Вкладки компактной компоновки и заголовок уведомлений сохранены.
+- Дополнительная строка латиницей появляется над основным названием при включённом `journeyTranslitEnabled`. Основное название сохраняется, уже латинские названия не дублируются. Это читаемая транслитерация, без перевода.
+- Настройка есть локально в «Экраны» и в админке устройств `polotsk-transit-api`. Старые настройки без нового поля используют `false`; миграция SQL не нужна.
+
+## Проверки
+
+- `ridebus-board`: TypeScript + Vite build; 84 теста пройдены, включая транслитерацию, сохранение настроек и совместимость удалённых конфигураций.
+- `polotsk-transit-api`: frontend build; 8 тестов сервиса устройств и 1 тест HTTP API пройдены. Новый флаг сохраняется при удалённом обновлении и возвращается в конфигурации устройства. Повреждённые значения отклоняются.
+- Браузер: 960×540, 879×593, 855×723, 390×844, 1280×800. На узкой и широкой карточке нет вертикального переполнения; латинская строка находится выше основного названия. На широкой компоновке нет пустого места от скрытого общего заголовка.
+- Обе темы и оба профиля проверены на 960×540. Включение и отключение флага сохраняются после перезагрузки.
+- Тестовые счётчики на переходах 1→2, 2→3, 3→4 с транслитом: в обоих профилях ноль изменений размеров блока названия и ноль записей размера шрифта за 1200 мс. Два заранее подготовленных слоя текста продолжают двигаться через transform/opacity. Это проверка работы DOM, а не измерение FPS на телевизоре.
+
+## Ограничения
+
+Локальный API стенда `127.0.0.1:5193` в проверочном браузере возвращал `ERR_BLOCKED_BY_CLIENT`. Сервер отдельно вернул валидный ответ и полное расписание с HTTP 200; визуальные проверки сделаны на встроенных демонстрационных данных. Xiaomi Mi Box 4 в этом запуске не проверялся.
+
+## Примеры
+
+- `journey-translit-960x540.png`: тёмная тема, ТВ-профиль, демонстрационные данные.
+- `journey-translit-street-light-960x540.png`: светлая тема, уличный профиль, демонстрационные данные.
+
+Для удалённого переключения нужно обновить табло, backend и frontend админки.
diff --git a/artifacts/verification-passenger-info.md b/artifacts/verification-passenger-info.md
new file mode 100644
index 0000000..dfec859
--- /dev/null
+++ b/artifacts/verification-passenger-info.md
@@ -0,0 +1,28 @@
+# Пассажирские блоки · 11 октября 2026
+
+Реализованы «До важных мест», напоминание о последнем рейсе перед ночным перерывом, вероятность осадков в ближайший час и оплата ближайшего доступного маршрута. Изменены Board и backend/frontend `/Users/xtimms/Projects/polotsk-transit-api`. Места в рабочей конфигурации по умолчанию пусты. Пользователь подтвердил тарифы для Полоцка: автобус 1,05 BYN, маршрутка 2,10 BYN, QR-кодом или наличными; дата подтверждения 2026-10-11 фиксирована.
+
+## Проверки кода
+
+- `npm test`: 96/96, включая короткие/обратные рейсы, подтверждённые отмены, фильтр маршрута, неизвестные временные смещения, прибытие на следующий день, ночные продолжения в обоих форматах дня обслуживания, дневные перерывы, выбор тарифа и изоляцию городов.
+- `npm run build` Board: TypeScript + Vite успешно.
+- `node --test test/boardDevices.test.js test/boardDevicesApi.test.js` в backend: 11/11. Проверены ограничения полей, даты, чужой город/маршрут, нормализация старых настроек, сохранение пустых массивов и авторизация существующего протокола.
+- `npm run build` frontend админки: успешно; прежние предупреждения о Browserslist/размере bundle остаются.
+- `git diff --check` обоих проектов: без ошибок.
+
+## Проверки браузера
+
+Использован production build на явном локальном стенде `test/fixtures/passenger-server.mjs`, отдельный origin 5194, фиктивные остановки/расписание/погода. Настройки основного origin 5180 и рабочая БД не изменялись.
+
+- Подтверждён действительный DOM viewport 960×540, 879×593, 855×723, 390×844 и 1440×900. Нет переполнения страницы и видимых новых карточек. На 1440×900 три карточки проверены одновременно; строки важных мест также не переполняются.
+- На 960×540 название места имеет размер около 43 px, тариф — около 82 px. Места разбиваются на три страницы; наблюдался автоматический переход к следующему месту. Число страниц удлиняет сцену до полного обзора. На высоком узком экране все три коротких тестовых места помещаются сразу.
+- Ближайший автобус показывает 1,05 BYN; после выбора маршрутки через видимый интерфейс стенда — 2,10 BYN. Видны способы оплаты и фиксированная дата подтверждения.
+- Погода показывает 80% в ближайший час. После локального выключения подсказка исчезает, после повторного включения появляется. Светлая тема и профиль «Уличное табло» проверены на 960×540.
+- Выключение важных мест сохраняется после перезагрузки и убирает вкладку. Данные мест и тарифов доступны в меню «Экраны». Кнопка «Ниже» прокрутила расширенное меню (DOM scrollTop 1099).
+- На стенде `test/fixtures/device-flow-server.mjs` устройство подключено кодом 0246; сохранены места, оба тарифа и переключатели. Реальный локальный HTTP-протокол передал цену устройству. Админка показала «Настройки применены» для v1, v2 (оплата выключена) и v3 (восстановлена); выключенная вкладка исчезла с устройства. Используется репозиторий в памяти, без PostgreSQL.
+- Устройство без полного расписания не показывает выбранное важное место, поскольку маршрут до него не подтверждён. Карточка оплаты продолжает работать по ближайшему доступному рейсу.
+- В консоли тестового приложения ошибок/предупреждений не обнаружено.
+
+Скриншоты: `passenger-places-960x540.png`, `passenger-weather-960x540.png`, `passenger-places-390x844.png`, `passenger-fare-minibus-960x540.jpg`, `passenger-weather-street-light-960x540.png`. Данные и время на них тестовые. Захват in-app browser через fullPage иногда пропускает неизменившиеся compositor layers; снимок не заменяет DOM-проверку заполнения/границ.
+
+Физический Mi Box / Hi Browser, FPS устройства, PostgreSQL integration test и развёртывание на рабочем сервере в этой проверке не выполнялись. Настройки хранятся в существующем JSON, новая миграция SQL не требуется; для удалённого управления обновляются оба проекта.
diff --git a/src/App.tsx b/src/App.tsx
index a0227c6..3b6e060 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -28,6 +28,10 @@ import type { ManagedState } from './useDeviceControl';
import { DeviceSetup, ManagedPanel } from './DeviceSetup';
import { nightWindow } from './lib/night';
import { NightScreen } from './NightScreen';
+import { PlacesCard, FareCard, LastTripNotice } from './PassengerInfo';
+import { passengerGuide } from './lib/passenger-guide';
+import type { PassengerGuide } from './lib/passenger-guide';
+import { demoGuideTimetable } from './lib/demo';
const clockFormat = new Intl.DateTimeFormat('ru', { timeZone: 'Europe/Minsk', hour: '2-digit', minute: '2-digit', second: '2-digit' });
const dateFormat = new Intl.DateTimeFormat('ru', { timeZone: 'Europe/Minsk', weekday: 'long', day: 'numeric', month: 'long' });
@@ -51,8 +55,9 @@ function DepartureStatus({ departure, now, large = false }: { departure: Display
;
}
-export function DeparturesScreen({ data, departures, now, alerts, loading, rowCount, fresh, error, paused, settings }: {
+export function DeparturesScreen({ data, departures, now, alerts, loading, rowCount, fresh, error, paused, settings, lastTripId }: {
data: BoardData | null; departures: DisplayDeparture[]; now: number; alerts: Alert[]; loading: boolean; rowCount: number; fresh: boolean; error: string; paused: boolean; settings: Settings;
+ lastTripId?: string | null;
}) {
const first = firstAvailable(departures);
const next = settings.showNextSameRoute ? nextSameRoute(departures, first) : undefined;
@@ -88,6 +93,7 @@ export function DeparturesScreen({ data, departures, now, alerts, loading, rowCo
{waiting ?
{localDate(Date.parse(first.scheduledAt)) > localDate(now) ? 'Завтра в' : 'Сегодня в'} {first.status === 'notice' && Есть изменения }
: }
: 0}>
{departures.length ? 'Ближайшие рейсы отменены' : loading ? 'Получаем расписание' : !data ? 'Расписание недоступно' : fresh ? 'На сегодня рейсов больше нет' : 'Сохранённые рейсы закончились'}
{departures.length ? 'Подробности — в информации о движении' : loading ? 'Ближайший транспорт появится здесь' : error === 'api_not_configured' ? 'Подключите RideBus API или включите деморежим в настройках' : !data || !fresh ? 'Ожидаем подключения к RideBus' : 'Новые рейсы появятся с началом движения'}
}
+ {first && first.id === lastTripId && }
{!first && departures.length ? 'ОТМЕНЁННЫЕ РЕЙСЫ' : remaining.some(item => item.status === 'cancelled') ? 'ДРУГИЕ РЕЙСЫ' : 'СЛЕДОМ ПРИБУДУТ'} {visible.pages > 1 ?
{visible.start + 1}{visible.items.length > 1 ? `–${visible.start + visible.items.length}` : ''} из {visible.count} :
}
1} style={{ '--rows': rows, '--row-height': `${tableHeight / rows}px` } as CSSProperties}>
{visible.items.map((departure, index) =>
@@ -104,17 +110,19 @@ export function DeparturesScreen({ data, departures, now, alerts, loading, rowCo
;
}
-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 }) {
+function InfoScreen({ data, alerts, fresh, seconds, paused, page, onPage, config, weather, now, settings, journey, onJourneyPages, guide, onPlacePages }: { 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;
+ guide: PassengerGuide; onPlacePages: (pages: number) => void }) {
const panel = useRef(null);
const grid = useRef(null);
const [compact, setCompact] = useState(false);
- const blocks = informationBlocks(settings);
+ const blocks = informationBlocks(settings, { places: !!guide.places.length, fare: !!guide.fare });
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]);
+ useEffect(() => { if (alerts.length || !settings.infoPlacesEnabled || compact && activeBlock !== 'places') onPlacePages(1); }, [alerts.length, settings.infoPlacesEnabled, compact, activeBlock, onPlacePages]);
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'); });
+ const observer = new ResizeObserver(entries => { const { width, height } = entries[0].contentRect; setCompact(blocks.length > 3 || width < 1000 || innerHeight > innerWidth || innerHeight < 650 || height < 330 || settings.displayProfile === 'street' || settings.viewingDistance === 'far'); });
observer.observe(grid.current);
return () => observer.disconnect();
}, [alerts.length, blocks.length, settings.displayProfile, settings.viewingDistance]);
@@ -128,15 +136,18 @@ function InfoScreen({ data, alerts, fresh, seconds, paused, page, onPage, config
}, 3500);
return () => { clearTimeout(start); clearInterval(timer); };
}, [seconds, paused, page]);
- return 3}
style={{ '--info-columns': blocks.length, '--info-tabs-width': `${blocks.length > 1 ? blocks.length * 100 : 0}px` } as CSSProperties} aria-label="Информация для пассажиров">
-
{alerts.length ? 'Изменения движения' : 'Информация'}
- {!alerts.length && blocks.length > 1 && {blocks.map((block, index) => onPage(index)}>{block.name} )} }
+ {(alerts.length > 0 || blocks.length > 1) &&
+ {alerts.length > 0 &&
Изменения движения }
+ {!alerts.length && blocks.length > 1 && {blocks.map((block, index) => onPage(index)}>{block.name} )} }}
{alerts.length ? :
{blocks.map(block =>
{block.id === 'service' && }
- {block.id === 'weather' && }
+ {block.id === 'weather' && }
{block.id === 'qr' && }
+ {block.id === 'places' && }
+ {block.id === 'fare' && guide.fare && }
)}
}
{!alerts.length && (data?.alertsAvailable === false || !fresh) && Предупреждения сейчас недоступны
}
@@ -186,6 +197,14 @@ 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 minuteKey = Math.floor(now / 60000);
+ const guideDemo = useMemo(() => settings.demo ? demoGuideTimetable(liveData, settings.stopId) : null, [settings.demo, settings.stopId, liveData]);
+ // Work once per minute or when the nearest trip/config/data changes, not on
+ // every clock digit: full-route searches should not tax the TV's CPU.
+ const guide = useMemo(() => passengerGuide(settings, settings.demo ? guideDemo : timetable.data, liveData, now, config.cityId, reliableAlerts),
+ [settings, guideDemo, timetable.data, liveData, minuteKey, config.cityId, reliableAlerts, first?.id, fullAvailable]);
+ const [placePages, setPlacePages] = useState(1);
+ const availablePlaces = !!guide.places.length, availableFare = !!guide.fare;
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);
@@ -193,8 +212,9 @@ function BoardApplication({ management }: { management?: { state: ManagedState;
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(() => 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 scenes = useMemo(() => night.active ? ['departures'] as Scene[] : boardScenes(settings, hasAlerts, urgent, { places: availablePlaces, fare: availableFare }), [settings, hasAlerts, urgent, night.active, availablePlaces, availableFare]);
+ const seconds = scene === 'departures' ? settings.mainSeconds : scene === 'info' ? Math.max(settings.infoSeconds, !hasAlerts && settings.infoServiceEnabled ? journeyPages * (reading.calm ? 6 : 4) : 0,
+ !hasAlerts && availablePlaces ? placePages * (reading.calm ? 10 : 7) : 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 || night.active });
@@ -278,12 +298,12 @@ function BoardApplication({ management }: { management?: { state: ManagedState;
{management?.failed && Нет связи с управлением · сохранённые настройки
}
{night.active && night.first &&
}
- {!night.active && scene === 'departures' &&
}
- {!night.active && scene === 'info' &&
}
+ {!night.active && scene === 'departures' &&
}
+ {!night.active && scene === 'info' &&
{ setInfoPage(value); setStartedAt(Date.now()); }} config={config} weather={weather} now={now} settings={settings} onJourneyPages={setJourneyPages} onPlacePages={setPlacePages} guide={guide}
+ journey={{ departure: first, journey, loading: timetable.loading || loading, paused: paused || settingsOpen, settings }} />}
{!night.active && scene === 'ad' && РЕКЛАМА · ПРЕДПРОСМОТР {settings.adTitle} {settings.adText}
}
- {!night.active && scene !== 'departures' && БЛИЖАЙШИЙ РЕЙС {first ? <>
{first.destination}
> :
Нет доступных рейсов }
}
+ {!night.active && scene !== 'departures' && БЛИЖАЙШИЙ РЕЙС {first ? <>
{first.destination} {guide.lastTripId === first.id && }
> :
Нет доступных рейсов }
}
{scenes.map((item, index) => goTo(item)} className={scene === item ? 'active' : ''} aria-label={`0${index + 1} ${night.active ? 'Ночной режим' : sceneNames[item]}`} title={night.active ? 'Ночной режим' : sceneNames[item]} aria-current={scene === item ? 'page' : undefined}>0{index + 1} {night.active ? 'Ночной режим' : sceneNames[item]} )}
{!fresh && !settings.demo && }{settings.demo ? 'Демонстрация' : held ? 'Приоритет транспорта' : `Обновлено в ${updateLabel}`}{data?.partial ? ' · часть рейсов недоступна' : ''}{offlineSaved === false ? ' · сохранение недоступно' : ''}
diff --git a/src/NextStops.tsx b/src/NextStops.tsx
index 5d3986e..3b24c98 100644
--- a/src/NextStops.tsx
+++ b/src/NextStops.tsx
@@ -1,17 +1,16 @@
import { memo, useEffect, useLayoutEffect, useRef, 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 { transliterateStopName } from './lib/transliteration';
import type { OnwardJourney } from './lib/journey';
import type { Departure, Settings } from './types';
import type { CSSProperties } from 'react';
// Fixed-size cells move on the compositor. Both text sizes are fitted once,
// so moving to the next stop never resizes a cell or remounts its heading.
-const JourneyRail = memo(function JourneyRail({ journey, focus }: { journey: OnwardJourney; focus: number }) {
+const JourneyRail = memo(function JourneyRail({ journey, focus, translit }: { journey: OnwardJourney; focus: number; translit: boolean }) {
const rail = useRef
(null);
const [geometry, setGeometry] = useState({ slot: 0, focused: 0, width: 0 });
useLayoutEffect(() => {
@@ -38,27 +37,32 @@ const JourneyRail = memo(function JourneyRail({ journey, focus }: { journey: Onw
'--journey-name-scale': geometry.focused > 0 ? geometry.slot / geometry.focused : .4 } as CSSProperties}>
- {journey.stops.map((stop, index) => focus} aria-current={index === focus ? 'step' : undefined}>
-
- {stop.minutes === null ? '—' : stop.minutes === 0 ? <><1 мин > : durationParts(stop.minutes).map(part => {part.unit} )}
-
-
-
- )}
+ {journey.stops.map((stop, index) => {
+ const latinName = translit ? transliterateStopName(stop.name) : stop.name;
+ return focus} aria-current={index === focus ? 'step' : undefined}>
+
+ {stop.minutes === null ? '—' : stop.minutes === 0 ? <><1 мин > : durationParts(stop.minutes).map(part => {part.unit} )}
+
+
+ {(['context', 'focus'] as const).map(size =>
+ {latinName !== stop.name && {latinName} }
+
+
)}
+
+
+ ;
+ })}
{journey.more > 0 && Ещё {journey.more} остановок }
;
-}, (previous, next) => previous.focus === next.focus && previous.journey.more === next.journey.more
+}, (previous, next) => previous.focus === next.focus && previous.translit === next.translit && previous.journey.more === next.journey.more
&& previous.journey.stops.length === next.journey.stops.length && previous.journey.stops.every((stop, index) => {
const other = next.journey.stops[index];
return stop.id === other.id && stop.name === other.name && stop.minutes === other.minutes;
}));
-export function NextStops({ departure, journey, loading, now, paused, settings, active, onPages }: {
- departure?: Departure; journey: OnwardJourney | null; loading: boolean; now: number; paused: boolean; settings: Settings;
+export function NextStops({ departure, journey, loading, paused, settings, active, onPages }: {
+ departure?: Departure; journey: OnwardJourney | null; loading: boolean; paused: boolean; settings: Settings;
active: boolean; onPages: (pages: number) => void;
}) {
const [page, setPage] = useState(0);
@@ -73,14 +77,12 @@ export function NextStops({ departure, journey, loading, now, paused, settings,
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 <>Дальше по маршруту
+ return <>Дальше по ближайшему рейсу
{!departure ? {loading ? 'Получаем расписание' : 'Нет доступного рейса'}
: <>
-
{!journey ? {loading ? 'Загружаем остановки' : 'Схема маршрута пока недоступна'}
: !count ? Здесь конечная этого рейса
: <>
0 && focus === 0}>
{settings.demo ? 'Демонстрация поездки' : 'После посадки'} {focus + 1}-я остановка
-
+
Время пути по расписанию После посадки · по расписанию {pages > 1 && {focus + 1} / {count} }
>}
diff --git a/src/PassengerInfo.tsx b/src/PassengerInfo.tsx
new file mode 100644
index 0000000..0e68e1c
--- /dev/null
+++ b/src/PassengerInfo.tsx
@@ -0,0 +1,54 @@
+import { useEffect, useLayoutEffect, useRef, useState } from 'react';
+import { RouteBadge } from './RouteBadge';
+import { FittedDestination } from './FittedDestination';
+import { NumericText } from './NumericText';
+import { Clock, Moon } from './Icons';
+import { localDate, shiftDate } from './lib/timetable.mjs';
+import { durationParts } from './lib/presentation.mjs';
+import { readability } from './lib/departures';
+import type { PlaceTrip } from './lib/passenger-guide';
+import type { FareTerms, Settings } from './types';
+import type { CSSProperties } from 'react';
+
+const format = new Intl.DateTimeFormat('ru', { timeZone: 'Europe/Minsk', hour: '2-digit', minute: '2-digit' });
+const checked = new Intl.DateTimeFormat('ru', { timeZone: 'Europe/Minsk', day: 'numeric', month: 'long', year: 'numeric' });
+function TripDay({ at, now }: { at: string; now: number }) {
+ const day = localDate(Date.parse(at)), today = localDate(now);
+ return day <= today ? null : {day === shiftDate(today, 1) ? 'завтра' : day === shiftDate(today, 2) ? 'послезавтра' : day.slice(5).split('-').reverse().join('.')} ;
+}
+export function LastTripNotice({ number, compact = false }: { number: string; compact?: boolean }) {
+ return Последний №{number} перед ночным перерывом · по расписанию
;
+}
+export function PlacesCard({ trips, now, paused, active, settings, onPages }: { trips: PlaceTrip[]; now: number; paused: boolean; active: boolean; settings: Settings; onPages: (pages: number) => void }) {
+ const body = useRef(null);
+ const [height, setHeight] = useState(0), [page, setPage] = useState(0);
+ const reading = readability(settings);
+ const size = Math.max(1, Math.min(3, Math.floor(height / (110 * reading.scale))));
+ const pages = Math.max(1, Math.ceil(trips.length / size));
+ const visible = trips.slice((page % pages) * size, (page % pages + 1) * size);
+ useLayoutEffect(() => {
+ if (!body.current) return;
+ const observer = new ResizeObserver(entries => setHeight(entries[0].contentRect.height)); observer.observe(body.current);
+ return () => observer.disconnect();
+ }, []);
+ useEffect(() => { if (active) onPages(pages); }, [active, pages, onPages]);
+ useEffect(() => { setPage(0); }, [settings.places]);
+ useEffect(() => {
+ if (paused || !active || pages < 2) return;
+ const timer = setInterval(() => setPage(value => value + 1), reading.calm ? 10000 : 7000);
+ return () => clearInterval(timer);
+ }, [paused, active, pages, reading.calm]);
+ return <>До важных мест
+ {visible.map((trip, index) =>
+
+ {trip.departure ?
+ {trip.minutes !== null && {trip.minutes === 0 ? '<1 мин' : durationParts(trip.minutes).map(part => {part.unit} )} }
+ :
Ближайший прямой рейс не найден
}
+
+ {trip.arrivalAt && Прибытие
}
+ )}
+
Прямые рейсы · по расписанию {pages > 1 && / {pages} }
>;
+}
+export function FareCard({ fare }: { fare: FareTerms }) {
+ return <>Оплата поездки Данные на {checked.format(Date.parse(`${fare.checkedAt}T12:00:00+03:00`))}
>;
+}
diff --git a/src/PassengerSettings.tsx b/src/PassengerSettings.tsx
new file mode 100644
index 0000000..5aab0c8
--- /dev/null
+++ b/src/PassengerSettings.tsx
@@ -0,0 +1,40 @@
+import { localDate } from './lib/timetable.mjs';
+import { demoPlaces } from './lib/demo';
+import type { Settings, Stop, Route } from './types';
+
+export function PassengerSettings({ settings, stops, routes, cityId, update }: { settings: Settings; stops: Stop[]; routes: Route[]; cityId: number; update: (key: K, value: Settings[K]) => void }) {
+ const targets = settings.demo ? demoPlaces.map(stop => ({ ...stop, direction: 'демо' })) : stops;
+ return
+
Важные места До трёх мест. Выберите конкретную остановку назначения и короткое название для пассажиров. Табло найдёт ближайший прямой рейс с учётом его направления и конечной.
+ {settings.places.map((place, index) =>
Место {index + 1}
+ Остановка назначения { const stop = targets.find(s => s.id === Number(e.target.value)); update('places', settings.places.map((item, i) => i === index ? { stopId: Number(e.target.value), name: stop?.name.slice(0, 48) || '' } : item)); }}>
+ Выберите остановку {!targets.some(stop => stop.id === place.stopId) && place.stopId > 0 && {place.name} · сохранённая остановка }
+ {targets.filter(stop => stop.id !== settings.stopId).map(stop => i !== index && p.stopId === stop.id)}>{stop.name}{stop.direction ? ` · ${stop.direction}` : ''} )}
+ Название на табло update('places', settings.places.map((item, i) => i === index ? { ...item, name: e.target.value } : item))} />
+ update('places', settings.places.filter((_, i) => i !== index))}>Удалить место {index + 1}
+ )}
+
= 3 || !targets.length} onClick={() => update('places', [...settings.places, { stopId: 0, name: '' }])}>Добавить место
+
Оплата поездки Основные тарифы по типу транспорта. Укажите подтверждённую цену с валютой, способы оплаты и дату проверки. Без подходящего тарифа карточка скрыта.
+ {(['bus', 'minibus'] as const).map(type => {
+ const rule = settings.fareByType.find(rule => rule.type === type && rule.cityId === cityId);
+ const label = type === 'bus' ? 'автобуса' : 'маршрутки';
+ const edit = (key: 'price' | 'payment' | 'checkedAt', value: string) => update('fareByType', settings.fareByType.map(item => item.type === type && item.cityId === cityId ? { ...item, [key]: value } : item));
+ return
Тариф {label}
+ Использовать тариф {label} update('fareByType', [...settings.fareByType.filter(item => item.type !== type), ...(e.target.checked ? [{ cityId, type, price: '', payment: '', checkedAt: localDate(Date.now()) }] : [])])} />
+ {rule && <>Стоимость с валютой edit('price', e.target.value)} /> Проверено edit('checkedAt', e.target.value)} />
+ Способы оплаты edit('payment', e.target.value)} /> >}
+ ;
+ })}
+
Исключения для маршрутов Тариф отдельного маршрута заменяет основной тариф его типа транспорта.
+ {settings.fareRules.map((rule, index) =>
Тариф {index + 1}
+ Маршрут update('fareRules', settings.fareRules.map((item, i) => i === index ? { ...item, routeId: Number(e.target.value) } : item))}>
+ Выберите маршрут {!routes.some(route => route.id === rule.routeId) && rule.routeId > 0 && Маршрут {rule.routeId} · сохранённый тариф }
+ {routes.map(route => i !== index && r.routeId === route.id)}>№{route.number} · {route.name} )}
+ Стоимость с валютой update('fareRules', settings.fareRules.map((item, i) => i === index ? { ...item, price: e.target.value } : item))} />
+ Проверено update('fareRules', settings.fareRules.map((item, i) => i === index ? { ...item, checkedAt: e.target.value } : item))} />
+ Способы оплаты update('fareRules', settings.fareRules.map((item, i) => i === index ? { ...item, payment: e.target.value } : item))} />
+ update('fareRules', settings.fareRules.filter((_, i) => i !== index))}>Удалить тариф {index + 1}
+ )}
+
= 32 || !routes.length} onClick={() => update('fareRules', [...settings.fareRules, { routeId: 0, price: '', payment: '', checkedAt: localDate(Date.now()) }])}>Добавить тариф
+
;
+}
diff --git a/src/SettingsPanel.tsx b/src/SettingsPanel.tsx
index 7edda73..f68e124 100644
--- a/src/SettingsPanel.tsx
+++ b/src/SettingsPanel.tsx
@@ -3,6 +3,7 @@ import { ArrowDown, ArrowUp, Check, Fullscreen, Reset, Close } from './Icons';
import { schemesForTheme } from './lib/presentation.mjs';
import { informationBlocks, informationOptions } from './lib/information';
import { useSettingsNavigation } from './useSettingsNavigation';
+import { PassengerSettings } from './PassengerSettings';
import type { BoardData, Config, Settings, Stop } from './types';
const sections = ['Остановка', 'Оформление', 'Экраны', 'Реклама'];
@@ -100,6 +101,10 @@ export function SettingsPanel({ settings, config, data, onSave, onClose, onFulls
Во время ночного перерыва длиннее двух часов. Обычный экран вернётся за выбранное время до первого рейса. Работает по полному расписанию; ночью остаются первый рейс, часы и уведомления.
Блоки информации
{informationOptions.map(block => {block.label} update(block.setting, event.target.checked)} /> )}
+ Транслит названий остановокЛатиницей над основным названием в схеме ближайшего рейса update('journeyTranslitEnabled', event.target.checked)} />
+ Напоминание о последнем рейсеЗа 30 минут до последнего рейса направления перед ночным перерывом update('lastTripNoticeEnabled', event.target.checked)} />
+ Вероятность осадковКороткая подсказка в погодном блоке update('weatherHintEnabled', event.target.checked)} />
+
{!informationBlocks(draft).length && Экран информации появится только при предупреждениях о движении.
}
Рекламный экран ПРОТОТИП
diff --git a/src/WeatherBlock.tsx b/src/WeatherBlock.tsx
index b2a0379..8958ddd 100644
--- a/src/WeatherBlock.tsx
+++ b/src/WeatherBlock.tsx
@@ -2,6 +2,7 @@ import { Cloud, Moon, Rain, Snow, Storm, Sun } from './Icons';
import { NumericText } from './NumericText';
import { weatherAvailable, weatherCondition } from './lib/weather.mjs';
import type { Weather } from './types';
+import { precipitationHint } from './lib/weather-hint';
const icons = { cloud: Cloud, moon: Moon, rain: Rain, snow: Snow, storm: Storm, sun: Sun };
const hourFormat = new Intl.DateTimeFormat('ru', { timeZone: 'Europe/Minsk', hour: '2-digit', minute: '2-digit' });
@@ -17,12 +18,14 @@ export function WeatherSummary({ data, now, failed }: { data: Weather | null; no
{weatherCondition(data.code, data.isDay).label} {failed && сохранено }
;
}
-export function WeatherCard({ data, now, loading, failed, demo }: { data: Weather | null; now: number; loading: boolean; failed: boolean; demo: boolean }) {
+export function WeatherCard({ data, now, loading, failed, demo, hintEnabled = true }: { data: Weather | null; now: number; loading: boolean; failed: boolean; demo: boolean; hintEnabled?: boolean }) {
const available = weatherAvailable(data, now);
if (!available || !data) return <>Погода {demo ? 'Прогноз доступен для реальной остановки' : loading ? 'Получаем прогноз…' : 'Прогноз недоступен'}
>;
const hours = data.hourly.filter(hour => Date.parse(hour.at) > now).slice(0, 3);
+ const probability = hintEnabled ? precipitationHint(data, now) : null;
return <>Погода
{weatherCondition(data.code, data.isDay).label}
{!!hours.length &&
}
+ {probability !== null && Вероятность осадков в ближайший час
}
>;
}
diff --git a/src/lib/demo.ts b/src/lib/demo.ts
index cc9b92c..af8bc96 100644
--- a/src/lib/demo.ts
+++ b/src/lib/demo.ts
@@ -1,4 +1,4 @@
-import type { BoardData } from '../types';
+import type { BoardData, Timetable } from '../types';
import { minskDate } from './board.mjs';
import type { Departure } from '../types';
import type { OnwardJourney } from './journey';
@@ -8,6 +8,19 @@ export function demoJourney(departure: Departure | undefined): OnwardJourney | n
return { stops: ['Площадь Свободы', 'Дом торговли', 'Поликлиника', departure.destination].map((name, index) => ({ id: 1000 + index, name, minutes: [3, 6, 9, 18][index] })), more: 0 };
}
+export const demoPlaces = [{ id: 1000, name: 'Площадь Свободы' }, { id: 1001, name: 'Дом торговли' }, { id: 1002, name: 'Поликлиника' }];
+export function demoGuideTimetable(data: BoardData | null, stopId: number): Timetable | null {
+ if (!data || data.mode !== 'demo') return null;
+ return { version: 1, cityId: data.cityId, cityName: data.cityName, stop: { ...data.stop, id: stopId },
+ updatedAt: data.updatedAt, expiresAt: new Date(Date.parse(data.updatedAt) + 86400000).toISOString(), holidays: [], calendarYears: [Number(data.serviceDate.slice(0, 4))],
+ routes: data.routes.map(route => ({ ...route, stops: [{ id: stopId, name: data.stop.name, sequence: 0, offsetMinutes: 0 },
+ ...demoPlaces.map((stop, i) => ({ ...stop, sequence: i + 1, offsetMinutes: (i + 1) * 3 })), { id: 1003, name: route.name, sequence: 4, offsetMinutes: 18 }],
+ schedules: (['weekday', 'saturday', 'sunday'] as const).map(dayType => ({ dayType, validFrom: null, validUntil: null,
+ trips: data.departures.filter(trip => trip.routeId === route.id).map(trip => ({ arrivalSeconds: Number(trip.scheduledTime.slice(0, 2)) * 3600 + Number(trip.scheduledTime.slice(3)) * 60,
+ originStopId: stopId, endStopId: 1003, destination: trip.destination, note: '', daysOfWeek: [], exceptDaysOfWeek: [] })) })) })),
+ };
+}
+
export function createDemo(now = Date.now()): BoardData {
const routes = [
{ id: 42, number: '16тк', name: 'Боровуха-3 — Больница (Новка)', destination: 'Больница (Новка)', type: 'minibus' },
diff --git a/src/lib/device.mjs b/src/lib/device.mjs
index 3f426df..79cc24d 100644
--- a/src/lib/device.mjs
+++ b/src/lib/device.mjs
@@ -1,8 +1,9 @@
import { colorSchemes, lightColorSchemes } from './presentation.mjs';
+import { passengerDefaults, validPlaces, validFareRules, validFareByType, confirmedPolotskFares } from './passenger-settings.mjs';
export const MANAGED_KEY = 'ridebus-board-managed-v1';
export function hydrateManagedState(value) {
if (value?.state !== 'paired' || !value.device?.settings || typeof value.device.settings !== 'object') return value;
- return { ...value, device: { ...value.device, settings: { nightMode: 'auto', nightWakeMinutes: 60, ...value.device.settings } } };
+ return { ...value, device: { ...value.device, settings: { ...passengerDefaults, fareByType: value.device.cityId === 1 ? confirmedPolotskFares : [], nightMode: 'auto', nightWakeMinutes: 60, journeyTranslitEnabled: false, ...value.device.settings } } };
}
export function isManagedState(value) {
value = hydrateManagedState(value);
@@ -13,6 +14,7 @@ export function isManagedState(value) {
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;
+ for (const key of ['holdNear', 'infoServiceEnabled', 'infoWeatherEnabled', 'infoQrEnabled', 'journeyTranslitEnabled', 'infoPlacesEnabled', 'infoFareEnabled', 'lastTripNoticeEnabled', 'weatherHintEnabled', 'showNextSameRoute', 'longWaitEnabled', 'adsEnabled']) if (typeof s[key] !== 'boolean') return false;
+ if (!validPlaces(s.places) || !validFareRules(s.fareRules) || !validFareByType(s.fareByType) || s.fareByType.some(rule => rule.cityId !== device.cityId)) return false;
return typeof s.routeFilter === 'string' && /^(|[1-9]\d{0,8})$/.test(s.routeFilter) && typeof s.adTitle === 'string' && s.adTitle.length <= 80 && typeof s.adText === 'string' && s.adText.length <= 200 && typeof config.cityName === 'string' && typeof config.stopName === 'string';
}
diff --git a/src/lib/information.ts b/src/lib/information.ts
index 64cb57c..4cb5883 100644
--- a/src/lib/information.ts
+++ b/src/lib/information.ts
@@ -1,16 +1,20 @@
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-код расписания' },
+ { id: 'places', setting: 'infoPlacesEnabled', name: 'Места', label: 'До важных мест' },
+ { id: 'fare', setting: 'infoFareEnabled', name: 'Оплата', label: 'Оплата поездки' },
] as const;
-export function informationBlocks(settings: Settings) {
- return informationOptions.filter(block => settings[block.setting]);
+export type InformationAvailable = { places: boolean; fare: boolean };
+export function informationBlocks(settings: Settings, available?: InformationAvailable) {
+ return informationOptions.filter(block => settings[block.setting] && (block.id !== 'places' || (available?.places ?? settings.places.length > 0))
+ && (block.id !== 'fare' || (available?.fare ?? settings.fareRules.length + settings.fareByType.length > 0)));
}
-export function boardScenes(settings: Settings, hasAlerts: boolean, urgent: boolean): Scene[] {
- return ['departures', ...(hasAlerts || informationBlocks(settings).length ? ['info'] : []),
+export function boardScenes(settings: Settings, hasAlerts: boolean, urgent: boolean, available?: InformationAvailable): Scene[] {
+ return ['departures', ...(hasAlerts || informationBlocks(settings, available).length ? ['info'] : []),
...(settings.adsEnabled && !urgent ? ['ad'] : [])] as Scene[];
}
diff --git a/src/lib/passenger-guide.ts b/src/lib/passenger-guide.ts
new file mode 100644
index 0000000..bbddbd0
--- /dev/null
+++ b/src/lib/passenger-guide.ts
@@ -0,0 +1,68 @@
+import { localDate, shiftDate, timetableAvailable, timetableDepartures } from './timetable.mjs';
+import { firstAvailable, displayDepartures } from './departures.ts';
+import { activeAlerts, upcomingDepartures } from './board.mjs';
+import { nightWindow } from './night.ts';
+import type { Settings, Timetable, BoardData, Departure, FareTerms, Place } from '../types';
+import type { DisplayDeparture } from './departures';
+
+type Segment = { stops: NonNullable; current: number; end: number; direction: number };
+// The actual trip endpoints matter: a route's full topology is not a promise
+// that this particular bus reaches every stop, or travels in both directions.
+function segment(bundle: Timetable, trip: Departure, stopId: number): Segment | null {
+ const stops = bundle.routes.find(route => route.id === trip.routeId)?.stops?.slice().sort((a, b) => a.sequence - b.sequence);
+ if (!stops?.length || !trip.originStopId || !trip.endStopId) return null;
+ const start = stops.findIndex(s => s.id === trip.originStopId), end = stops.findIndex(s => s.id === trip.endStopId);
+ const current = stops.findIndex(s => s.id === stopId);
+ if (start < 0 || end < 0 || current < Math.min(start, end) || current > Math.max(start, end) || start === end) return null;
+ return { stops, current, end, direction: Math.sign(end - start) };
+}
+export type PlaceTrip = { place: Place; departure: DisplayDeparture | null; minutes: number | null; arrivalAt: string | null };
+export type PassengerGuide = { places: PlaceTrip[]; fare: FareTerms | null; lastTripId: string | null };
+
+export function passengerGuide(settings: Settings, bundle: Timetable | null, data: BoardData | null, now: number, cityId: number, reliableAlerts: boolean): PassengerGuide {
+ const result: PassengerGuide = { places: [], fare: null, lastTripId: null };
+ if (data && data.mode !== 'demo' && (data.cityId !== cityId || data.stop.id !== settings.stopId)) data = null;
+ const available = Boolean(bundle && bundle.cityId === cityId && bundle.stop.id === settings.stopId && timetableAvailable(bundle, now));
+ const date = localDate(now);
+ const raw = available ? timetableDepartures(bundle, date) as Departure[] : upcomingDepartures(data, now, settings.routeFilter) as Departure[];
+ const alerts = activeAlerts(data?.alerts || [], now, settings.routeFilter, data?.routes || []);
+ const all = displayDepartures(raw, alerts, settings.stopId, now, reliableAlerts);
+ const upcoming = all.filter(trip => Date.parse(trip.scheduledAt) + 60000 > now && (!settings.routeFilter || String(trip.routeId) === settings.routeFilter));
+ const first = firstAvailable(upcoming);
+ // A future verification date must not publish a tariff early.
+ result.fare = settings.infoFareEnabled && first ? settings.fareRules.find(rule => rule.routeId === first.routeId && rule.checkedAt <= date)
+ || settings.fareByType.find(rule => rule.cityId === cityId && rule.type === first.type && rule.checkedAt <= date) || null : null;
+ if (!available || !bundle) return result;
+ if (settings.infoPlacesEnabled && settings.places.length && bundle.routes.some(route => route.stops?.length)) {
+ result.places = settings.places.filter(place => place.stopId !== settings.stopId).map(place => {
+ for (const trip of upcoming) {
+ if (trip.status === 'cancelled') continue;
+ const path = segment(bundle, trip, settings.stopId);
+ if (!path) continue;
+ const target = path.stops.findIndex(stop => stop.id === place.stopId);
+ if (target < 0 || path.direction * (target - path.current) <= 0 || path.direction * (path.end - target) < 0) continue;
+ const span = path.direction > 0 ? path.stops.slice(path.current, target + 1) : path.stops.slice(target, path.current + 1).reverse();
+ const timed = span.every((stop, i) => stop.offsetMinutes !== null && (!i || span[i - 1].offsetMinutes !== null
+ && path.direction * (stop.offsetMinutes - span[i - 1].offsetMinutes!) >= 0));
+ const minutes = timed ? Math.ceil(path.direction * (path.stops[target].offsetMinutes! - path.stops[path.current].offsetMinutes!)) : null;
+ return { place, departure: trip, minutes, arrivalAt: minutes === null ? null : new Date(Date.parse(trip.scheduledAt) + minutes * 60000).toISOString() };
+ }
+ return { place, departure: null, minutes: null, arrivalAt: null };
+ });
+ }
+ if (!settings.lastTripNoticeEnabled || settings.demo || !first || Date.parse(first.scheduledAt) - now > 30 * 60000) return result;
+ const firstPath = segment(bundle, first, settings.stopId);
+ if (!firstPath) return result;
+ const closingDate = localDate(Date.parse(first.scheduledAt));
+ if (![-1, 0, 1].every(days => bundle.calendarYears.includes(Number(shiftDate(closingDate, days).slice(0, 4))))) return result;
+ const pool = [...new Map([...timetableDepartures(bundle, shiftDate(closingDate, -1)), ...timetableDepartures(bundle, closingDate)]
+ .map(trip => [trip.id, trip])).values()] as Departure[];
+ const directionTrips = displayDepartures(pool, alerts, settings.stopId, now, reliableAlerts).filter(trip =>
+ trip.routeId === first.routeId && segment(bundle, trip, settings.stopId)?.direction === firstPath.direction);
+ // Reuse the overnight boundary policy, including after-midnight service tails.
+ // Looking just after boarding proves that this trip starts the closing gap.
+ const closing = nightWindow({ ...settings, demo: false, nightMode: 'auto', nightWakeMinutes: 60, routeFilter: String(first.routeId) },
+ directionTrips, Date.parse(first.scheduledAt) + 60001, true);
+ if (closing.active) result.lastTripId = first.id;
+ return result;
+}
diff --git a/src/lib/passenger-settings.mjs b/src/lib/passenger-settings.mjs
new file mode 100644
index 0000000..b5598c8
--- /dev/null
+++ b/src/lib/passenger-settings.mjs
@@ -0,0 +1,30 @@
+import { validDate } from './timetable.mjs';
+
+const object = value => value && typeof value === 'object' && !Array.isArray(value);
+const text = (value, max) => typeof value === 'string' && value.trim().length > 0 && value.length <= max;
+const id = (value, max = 1000000) => Number.isSafeInteger(value) && value > 0 && value <= max;
+export function validPlaces(value) {
+ return Array.isArray(value) && value.length <= 3 && new Set(value.map(item => item?.stopId)).size === value.length
+ && value.every(item => object(item) && Object.keys(item).every(key => ['stopId', 'name'].includes(key))
+ && id(item.stopId) && text(item.name, 48));
+}
+export function validFareRules(value) {
+ return Array.isArray(value) && value.length <= 32 && new Set(value.map(item => item?.routeId)).size === value.length
+ && value.every(item => object(item) && Object.keys(item).every(key => ['routeId', 'price', 'payment', 'checkedAt'].includes(key))
+ && id(item.routeId, 999999999) && text(item.price, 40) && text(item.payment, 100) && validDate(item.checkedAt));
+}
+export function validFareByType(value) {
+ return Array.isArray(value) && value.length <= 2 && new Set(value.map(item => item?.type)).size === value.length
+ && value.every(item => object(item) && Object.keys(item).every(key => ['cityId', 'type', 'price', 'payment', 'checkedAt'].includes(key))
+ && id(item.cityId) && ['bus', 'minibus'].includes(item.type) && text(item.price, 40) && text(item.payment, 100) && validDate(item.checkedAt));
+}
+// Confirmed by the operator on 2026-10-11 for Polotsk, not a provider estimate.
+// Keep that verification date fixed and bind the values to the actual city.
+export const confirmedPolotskFares = [
+ { cityId: 1, type: 'bus', price: '1,05 BYN', payment: 'QR-кодом или наличными', checkedAt: '2026-10-11' },
+ { cityId: 1, type: 'minibus', price: '2,10 BYN', payment: 'QR-кодом или наличными', checkedAt: '2026-10-11' },
+];
+export const passengerDefaults = {
+ infoPlacesEnabled: true, infoFareEnabled: true, lastTripNoticeEnabled: true, weatherHintEnabled: true,
+ places: [], fareRules: [], fareByType: [],
+};
diff --git a/src/lib/storage.ts b/src/lib/storage.ts
index cf5610e..74fbd23 100644
--- a/src/lib/storage.ts
+++ b/src/lib/storage.ts
@@ -4,11 +4,17 @@ import { colorScheme } from './presentation.mjs';
import { isTimetable } from './timetable.mjs';
import { isWeather } from './weather.mjs';
import type { Timetable, Weather } from '../types';
+import { validPlaces, validFareRules, validFareByType, confirmedPolotskFares } from './passenger-settings.mjs';
+import type { FareTypeRule } from '../types';
const SETTINGS_KEY = 'ridebus-board-settings-v1';
export const defaultSettings: Settings = { stopId: 1, demo: false, mainSeconds: 25, infoSeconds: 8, holdNear: true,
rowCount: 4, routeFilter: '', theme: 'dark', colorScheme: 'lime', lightColorScheme: 'blue',
infoServiceEnabled: true, infoWeatherEnabled: true, infoQrEnabled: true,
+ journeyTranslitEnabled: false,
+ infoPlacesEnabled: true, infoFareEnabled: true, lastTripNoticeEnabled: true, weatherHintEnabled: true, places: [], fareRules: [],
+ fareByType: [{ cityId: 1, type: 'bus', price: '1,05 BYN', payment: 'QR-кодом или наличными', checkedAt: '2026-10-11' },
+ { cityId: 1, type: 'minibus', price: '2,10 BYN', payment: 'QR-кодом или наличными', checkedAt: '2026-10-11' }],
showNextSameRoute: true, longWaitEnabled: true, displayProfile: 'tv', viewingDistance: 'near',
nightMode: 'auto', nightWakeMinutes: 60,
adsEnabled: false, adSeconds: 7, adTitle: 'Ваше место в городе', adText: 'Короткая история. Большая аудитория.' };
@@ -21,6 +27,11 @@ export function readSettings(): Settings {
holdNear: saved.holdNear !== false, routeFilter: typeof saved.routeFilter === 'string' && /^\d*$/.test(saved.routeFilter) ? saved.routeFilter : '',
theme: saved.theme === 'light' ? 'light' : 'dark', colorScheme: colorScheme(saved.colorScheme).id, lightColorScheme: colorScheme(saved.lightColorScheme, 'light').id,
infoServiceEnabled: saved.infoServiceEnabled !== false, infoWeatherEnabled: saved.infoWeatherEnabled !== false, infoQrEnabled: saved.infoQrEnabled !== false,
+ journeyTranslitEnabled: saved.journeyTranslitEnabled === true,
+ infoPlacesEnabled: saved.infoPlacesEnabled !== false, infoFareEnabled: saved.infoFareEnabled !== false,
+ lastTripNoticeEnabled: saved.lastTripNoticeEnabled !== false, weatherHintEnabled: saved.weatherHintEnabled !== false,
+ places: validPlaces(saved.places) ? saved.places : [], fareRules: validFareRules(saved.fareRules) ? saved.fareRules : [],
+ fareByType: saved.fareByType === undefined ? confirmedPolotskFares as FareTypeRule[] : validFareByType(saved.fareByType) ? saved.fareByType : [],
showNextSameRoute: saved.showNextSameRoute !== false, longWaitEnabled: saved.longWaitEnabled !== false,
displayProfile: saved.displayProfile === 'street' ? 'street' : 'tv', viewingDistance: ['medium', 'far'].includes(saved.viewingDistance) ? saved.viewingDistance : 'near',
nightMode: saved.nightMode === 'off' ? 'off' : 'auto', nightWakeMinutes: saved.nightWakeMinutes === 45 ? 45 : 60,
diff --git a/src/lib/transliteration.ts b/src/lib/transliteration.ts
new file mode 100644
index 0000000..e771de6
--- /dev/null
+++ b/src/lib/transliteration.ts
@@ -0,0 +1,19 @@
+// Readable Latin spelling for Russian and Belarusian stop names. Names,
+// punctuation and route numbers stay intact; this does not translate them.
+const letters: Record = {
+ а: 'a', б: 'b', в: 'v', г: 'g', д: 'd', е: 'e', ё: 'yo', ж: 'zh', з: 'z',
+ и: 'i', й: 'y', к: 'k', л: 'l', м: 'm', н: 'n', о: 'o', п: 'p', р: 'r',
+ с: 's', т: 't', у: 'u', ф: 'f', х: 'kh', ц: 'ts', ч: 'ch', ш: 'sh', щ: 'shch',
+ ъ: '', ы: 'y', ь: '', э: 'e', ю: 'yu', я: 'ya', і: 'i', ў: 'u',
+};
+
+export function transliterateStopName(name: string): string {
+ return name.replace(/[А-ЯЁІЎа-яёіў]+/g, word => {
+ const allCaps = word.length > 1 && word === word.toUpperCase();
+ return Array.from(word, letter => {
+ const spelling = letters[letter.toLowerCase()];
+ if (allCaps) return spelling.toUpperCase();
+ return letter === letter.toUpperCase() ? spelling.charAt(0).toUpperCase() + spelling.slice(1) : spelling;
+ }).join('');
+ });
+}
diff --git a/src/lib/weather-hint.ts b/src/lib/weather-hint.ts
new file mode 100644
index 0000000..cf4ff97
--- /dev/null
+++ b/src/lib/weather-hint.ts
@@ -0,0 +1,8 @@
+import { weatherAvailable } from './weather.mjs';
+import type { Weather } from '../types';
+
+export function precipitationHint(data: Weather | null, now: number) {
+ if (!data || !weatherAvailable(data, now)) return null;
+ const hour = data.hourly.find(hour => Date.parse(hour.at) > now && Date.parse(hour.at) <= now + 3600000);
+ return hour ? Math.round(hour.precipitationProbability) : null;
+}
diff --git a/src/styles.css b/src/styles.css
index 5a6d1ec..441ad0d 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -82,6 +82,8 @@ h1 { font-size: clamp(30px, 3.8vw, 76px); letter-spacing: -.05em; line-height: 1
/* Cards switch to one panel when the actual available area is too small. */
.info-screen { height: 100%; min-height: 0; display: flex; flex-direction: column; padding: 16px 0; overflow-y: auto; scrollbar-width: thin; scrollbar-color: var(--outline) var(--background); }
.info-intro { flex-shrink: 0; display: flex; justify-content: space-between; align-items: center; gap: 16px; margin-bottom: 16px; }
+.info-intro[data-tabs-only=true] { display: none; }
+.info-screen[data-compact=true] .info-intro[data-tabs-only=true] { display: flex; flex-direction: row; align-items: center; justify-content: flex-end; }
.info-intro h2 { margin: 0; font-size: clamp(28px, 3vw, 56px); letter-spacing: -.045em; font-weight: 550; }
.information-tabs { display: none; gap: 8px; }
.information-tabs button { font-size: 14px; border: 1px solid var(--outline); background: transparent; padding: 8px 12px; border-radius: 10px; color: var(--secondary); }
@@ -103,13 +105,6 @@ h1 { font-size: clamp(30px, 3.8vw, 76px); letter-spacing: -.05em; line-height: 1
.route-list-pages { display: flex; justify-content: flex-end; color: var(--muted); font-size: 16px; padding-top: 10px; }
/* 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; }
@@ -132,6 +127,8 @@ h1 { font-size: clamp(30px, 3.8vw, 76px); letter-spacing: -.05em; line-height: 1
.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-name-focus .destination-heading { font-size: calc(clamp(48px, 11cqw, 96px) * var(--reading-scale, 1)); font-weight: 650; letter-spacing: -.055em; color: var(--foreground); }
+.journey-transliteration { flex-shrink: 0; color: var(--muted); font-size: calc(clamp(14px, 2.2cqw, 20px) * var(--reading-scale, 1)); font-weight: 500; line-height: 1.2; letter-spacing: -.01em; overflow-wrap: anywhere; margin-bottom: 6px; }
+.journey-name-focus .journey-transliteration { font-size: calc(clamp(17px, 3.1cqw, 28px) * var(--reading-scale, 1)); }
.journey-duration { align-self: start; display: flex; align-items: baseline; gap: 8px; color: var(--muted); font-size: clamp(24px, 4.5cqw, 36px); font-weight: 550; line-height: 1; white-space: nowrap; transition: opacity 300ms ease-out; }
.journey-duration > span { display: inline-flex; align-items: baseline; gap: 5px; }
.journey-duration small { font-size: .72em; font-weight: 500; }
@@ -152,8 +149,6 @@ h1 { font-size: clamp(30px, 3.8vw, 76px); letter-spacing: -.05em; line-height: 1
.journey-metro[data-loop=true] .journey-station, .journey-metro[data-loop=true] .journey-continuation, .journey-metro[data-loop=true] .journey-line, .journey-metro[data-loop=true] .journey-name-layer { 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-name-focus .destination-heading { font-size: calc(clamp(34px, 11cqw, 46px) * var(--reading-scale, 1)); }
.journey-name-layer { padding-right: 22px; }
@@ -161,10 +156,6 @@ h1 { font-size: clamp(30px, 3.8vw, 76px); letter-spacing: -.05em; line-height: 1
.journey-caption { font-size: 14px; gap: 4px 10px; }
}
@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: auto minmax(0, 1fr) 32px; gap: 6px; }
@@ -184,10 +175,6 @@ h1 { font-size: clamp(30px, 3.8vw, 76px); letter-spacing: -.05em; line-height: 1
.journey-caption-full { display: none; }
.journey-caption-compact { display: inline; }
}
-@container journey-card (max-height: 260px) {
- /* The same route and departure time remain visible in the persistent next-trip bar. */
- .journey-route { display: none; }
-}
@media (prefers-reduced-motion: reduce) {
.journey-station, .journey-continuation, .journey-line, .journey-name-layer, .journey-duration, .service-info .journey-station .destination-heading { transition: none !important; will-change: auto !important; }
.journey-metro .destination-current, .journey-metro[data-loop=true] .journey-rail-window { animation: none; }
@@ -252,6 +239,50 @@ h1 { font-size: clamp(30px, 3.8vw, 76px); letter-spacing: -.05em; line-height: 1
.info-screen[data-compact='true'] .information-tabs { display: flex; }
.info-screen[data-compact='true'] .info-card:not([data-active='true']) { display: none; }
.info-screen[data-compact='true'] .card-icon { display: none; }
+.last-trip-notice { display: flex; align-items: center; gap: 9px; flex-shrink: 0; padding: 9px 12px; border-radius: 12px; color: var(--warning); background: color-mix(in srgb, var(--warning) 10%, var(--background)); font-size: clamp(14px, 1.4vw, 24px); line-height: 1.3; }
+.last-trip-notice svg { flex-shrink: 0; }
+.last-trip-notice small { font-size: .85em; color: var(--secondary); }
+.last-trip-compact { background: transparent; padding: 3px 0 0; font-size: 13px; }
+.last-trip-compact svg, .last-trip-compact small { display: none; }
+.places-info { container-name: places-card; }
+.places-body { flex: 1 1 0; min-height: 0; display: grid; grid-template-rows: repeat(var(--place-rows, 1), minmax(0, 1fr)); gap: 12px; }
+.place-trip { min-height: 0; min-width: 0; display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 24px; border-bottom: 1px solid var(--line); padding: 10px 0; }
+.place-trip:last-child { border-bottom: none; }
+.place-copy { min-height: 0; min-width: 0; display: flex; flex-direction: column; justify-content: center; }
+.places-info .place-copy .destination-heading { font-size: calc(clamp(24px, 5cqw, 52px) * var(--reading-scale, 1)); font-weight: 600; line-height: 1.13; letter-spacing: -.04em; margin: 0; overflow-wrap: anywhere; }
+.place-details { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 16px; margin-top: 10px; color: var(--secondary); font-size: clamp(18px, 3.2cqw, 30px); }
+.place-details .route-badge { width: auto; min-width: 48px; height: 36px; border-radius: 10px; padding: 0 9px; font-size: 24px; }
+.place-details time { display: inline-flex; align-items: baseline; gap: 8px; color: var(--accent); font-weight: 600; }
+.place-details small, .place-arrival small { font-size: .6em; color: var(--secondary); font-weight: 400; }
+.place-duration { display: flex; align-items: center; gap: 6px; font-size: .8em; }
+.place-arrival { display: flex; flex-direction: column; align-items: flex-end; justify-content: center; gap: 8px; color: var(--secondary); font-size: clamp(14px, 2cqw, 20px); }
+.place-arrival time { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: flex-end; gap: 8px; color: var(--foreground); font-weight: 600; font-size: calc(clamp(30px, 5.2cqw, 54px) * var(--reading-scale, 1)); }
+.place-trip .place-unavailable { font-size: 16px; margin: 8px 0 0; }
+.passenger-caption { flex-shrink: 0; display: flex; justify-content: space-between; gap: 12px; color: var(--muted); font-size: clamp(13px, 1.2vw, 20px); padding-top: 12px; line-height: 1.3; }
+.fare-info { container-name: fare-card; }
+.fare-body { flex: 1 1 0; min-height: 0; display: grid; grid-template-rows: auto minmax(0, 1.8fr) minmax(0, .8fr); align-items: stretch; gap: 8px; text-align: center; padding: 6px 0; }
+.fare-label { font-size: clamp(16px, 2cqw, 24px); color: var(--secondary); }
+.fare-price, .fare-payment { min-height: 0; display: flex; align-items: center; justify-content: center; }
+.fare-price .destination-heading { color: var(--accent); margin: 0; font-size: clamp(40px, 15cqw, 130px); line-height: 1.08; font-weight: 650; letter-spacing: -.06em; overflow-wrap: anywhere; }
+.fare-info .fare-payment .destination-heading { color: var(--secondary); margin: 0; font-size: clamp(22px, 4cqw, 40px); font-weight: 500; line-height: 1.3; overflow-wrap: anywhere; }
+@container fare-card (max-height: 300px) {
+ .fare-label { display: none; }
+ .fare-body { grid-template-rows: minmax(0, 1.8fr) minmax(0, .8fr); padding: 0; }
+}
+.weather-hint { display: flex; align-items: center; justify-content: center; gap: 10px; padding-top: 10px; color: var(--secondary); font-size: clamp(15px, 2.6cqw, 24px); line-height: 1.3; }
+.weather-hint svg { flex-shrink: 0; color: var(--accent); }
+.weather-hint strong { color: var(--accent); white-space: nowrap; font-size: 1.2em; font-weight: 600; }
+.passenger-editor { min-width: 0; border: 1px solid var(--outline); border-radius: 14px; padding: 16px; margin: 12px 0; }
+.passenger-editor legend { color: var(--secondary); padding: 0 6px; font-size: 14px; }
+.passenger-editor .text-button { font-size: 14px; padding: 8px 0; }
+@container places-card (max-width: 440px) {
+ .place-trip { gap: 14px; }
+ .place-details { gap: 6px 10px; font-size: 21px; }
+ .place-details .route-badge { height: 30px; font-size: 20px; min-width: 40px; }
+ .place-duration { flex-basis: 100%; }
+ .place-arrival { font-size: 12px; }
+ .place-arrival time { font-size: 30px; }
+}
.info-status { display: flex; align-items: center; gap: 10px; flex-shrink: 0; color: var(--secondary); font-size: clamp(14px, 1.2vw, 22px); padding-top: 14px; line-height: 1.3; }
.info-status svg { color: var(--accent); }
.info-notices { display: grid; gap: 18px; }
@@ -507,6 +538,16 @@ h1 { font-size: clamp(30px, 3.8vw, 76px); letter-spacing: -.05em; line-height: 1
.weather-info { padding: 16px; }
}
/* Viewing distance raises type sizes; pagination preserves the available height. */
+/* More cards rotate one at a time. Keep their controls above the title so the
+ additional tabs never consume the name's width on a short TV viewport. */
+.info-screen[data-compact=true][data-many-blocks=true][data-cards=true] .info-intro { position: static; margin: 0 0 10px; flex-shrink: 0; }
+.info-screen[data-compact=true][data-many-blocks=true][data-cards=true] .info-card > h3 { padding-right: 0; min-height: 0; display: block; }
+.info-screen[data-many-blocks=true] .information-tabs { flex-wrap: wrap; justify-content: flex-end; gap: 6px; }
+.info-screen[data-many-blocks=true] .information-tabs button { padding: 7px 10px; font-size: 13px; }
+@media (max-width: 440px) {
+ .info-screen[data-many-blocks=true] .information-tabs button { font-size: 12px; padding: 6px 8px; }
+ .weather-hint { font-size: 14px; gap: 7px; }
+}
.board { --reading-scale: 1; }
.board[data-profile='street'], .board[data-distance='medium'], .board[data-distance='far'] { --hero-height: clamp(125px, calc(18vh * var(--reading-scale)), 270px); }
.board h1 { font-size: clamp(30px, calc(3.8vw * var(--reading-scale)), 90px); }
diff --git a/src/types.ts b/src/types.ts
index 0fa50fd..4c2e63a 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -11,9 +11,16 @@ export type Stop = { id: number; name: string; direction: string };
export type BoardData = { cityId: number; cityName: string; stop: Stop; departures: Departure[]; routes: Route[];
alerts: Alert[]; alertsAvailable: boolean; partial: boolean; updatedAt: string; serverTime?: string; serviceDate: string; mode: 'live' | 'demo' };
export type Config = { cityId: number; cityName: string; stopName: string; defaultStopId: number; configured: boolean; refreshSeconds: number; timeZone: string; passengerBaseUrl?: string };
+export type Place = { stopId: number; name: string };
+export type FareTerms = { price: string; payment: string; checkedAt: string };
+export type FareRule = FareTerms & { routeId: number };
+export type FareTypeRule = FareTerms & { cityId: number; type: 'bus' | 'minibus' };
export type Settings = { stopId: number; demo: boolean; mainSeconds: number; infoSeconds: number; holdNear: boolean;
rowCount: number; routeFilter: string; theme: 'dark' | 'light'; colorScheme: string; lightColorScheme: string;
infoServiceEnabled: boolean; infoWeatherEnabled: boolean; infoQrEnabled: boolean;
+ journeyTranslitEnabled: boolean;
+ infoPlacesEnabled: boolean; infoFareEnabled: boolean; lastTripNoticeEnabled: boolean; weatherHintEnabled: boolean;
+ places: Place[]; fareRules: FareRule[]; fareByType: FareTypeRule[];
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 };
diff --git a/test/device.test.mjs b/test/device.test.mjs
index c69d032..92289f8 100644
--- a/test/device.test.mjs
+++ b/test/device.test.mjs
@@ -8,6 +8,8 @@ const source = await readFile(new URL('../src/lib/storage.ts', import.meta.url),
// Settings used by the actual board; no external project is required for these tests.
const body = source.match(/export const defaultSettings: Settings = ([\s\S]*?);\nexport function/)[1];
const defaults = Function(`return (${body})`)();
+// This fixture's device belongs to city 2; Polotsk tariffs must not leak into it.
+defaults.fareByType = [];
const value = { state: 'paired', enabled: true, adminUrl: '', device: { id: 'test-device', name: 'Табло', cityId: 2, revision: 1, settings: defaults }, config: { configured: true, cityId: 2, cityName: 'Новополоцк', stopName: 'Остановка', defaultStopId: 1, refreshSeconds: 20, timeZone: 'Europe/Minsk' } };
test('managed configuration validation rejects damaged, mismatched or unsupported settings', () => {
assert.equal(isManagedState(value), true);
@@ -18,6 +20,27 @@ test('managed configuration validation rejects damaged, mismatched or unsupporte
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('managed transliteration settings accept legacy devices and reject malformed flags', () => {
+ const legacy = structuredClone(value); delete legacy.device.settings.journeyTranslitEnabled;
+ assert.equal(isManagedState(legacy), true);
+ assert.equal(hydrateManagedState(legacy).device.settings.journeyTranslitEnabled, false);
+ const enabled = { ...value, device: { ...value.device, settings: { ...defaults, journeyTranslitEnabled: true } } };
+ assert.equal(isManagedState(enabled), true);
+ assert.equal(hydrateManagedState(enabled).device.settings.journeyTranslitEnabled, true);
+ for (const flag of ['true', 1, null]) {
+ assert.equal(isManagedState({ ...value, device: { ...value.device, settings: { ...defaults, journeyTranslitEnabled: flag } } }), false);
+ }
+});
+test('managed passenger settings migrate without leaking another city tariff or accepting damaged arrays', () => {
+ const legacy = structuredClone(value);
+ for(const key of ['infoPlacesEnabled','infoFareEnabled','lastTripNoticeEnabled','weatherHintEnabled','places','fareRules','fareByType']) delete legacy.device.settings[key];
+ assert.equal(isManagedState(legacy),true);assert.deepEqual(hydrateManagedState(legacy).device.settings.fareByType,[]);
+ const polotsk={...legacy,device:{...legacy.device,cityId:1},config:{...legacy.config,cityId:1}};
+ assert.equal(hydrateManagedState(polotsk).device.settings.fareByType[0].price,'1,05 BYN'); assert.equal(isManagedState(polotsk),true);
+ for(const patch of [{places:null},{fareRules:[{routeId:1}]},{weatherHintEnabled:'true'},{fareByType:[{cityId:1,type:'bus',price:'1 BYN',payment:'Наличные',checkedAt:'2026-10-11'}]}]) {
+ assert.equal(isManagedState({...value,device:{...value.device,settings:{...defaults,...patch}}}),false);
+ }
+});
test('pairing bridge keeps secrets in HttpOnly cookies, binds data to the individual device and rejects cross-site writes', async t => {
const tokenA = 'A'.repeat(43), tokenB = 'B'.repeat(43), calls = [];
let revoked = false;
diff --git a/test/fixtures/passenger-server.mjs b/test/fixtures/passenger-server.mjs
new file mode 100644
index 0000000..a469724
--- /dev/null
+++ b/test/fixtures/passenger-server.mjs
@@ -0,0 +1,50 @@
+// Explicit local UI fixture. No production database, telemetry or transit API.
+import express from 'express';
+import { fileURLToPath } from 'node:url';
+import { createApp } from '../../server/index.mjs';
+import { createDeviceBridge } from '../../server/device.mjs';
+import { defaultSettings } from '../../src/lib/storage.ts';
+import { localDate, timetableDepartures } from '../../src/lib/timetable.mjs';
+
+let firstType = 'bus', started = Date.now();
+const stop = { id: 1, name: 'Тестовая остановка', direction: 'Фиктивное расписание' };
+const targets = [{ id: 901, name: 'Больница', sequence: 1, offsetMinutes: 12 }, { id: 902, name: 'Вокзал', sequence: 2, offsetMinutes: 20 }, { id: 903, name: 'Центр', sequence: 3, offsetMinutes: 28 }];
+function resources() {
+ const now = Date.now(), start = Math.floor(started / 60000) * 60000;
+ const t = { version: 1, cityId: 1, cityName: 'Полоцк', stop, routes: ['bus', 'minibus'].map((type, i) => {
+ const at = new Date(start + (type === firstType ? 20 : 40) * 60000);
+ const civil = new Date(at.getTime() + 10800000);
+ const seconds = civil.getUTCHours() * 3600 + civil.getUTCMinutes() * 60 + (localDate(at) > localDate(start) ? 86400 : 0);
+ return { id: i + 1, number: type === 'bus' ? '1' : '2тк', name: 'Тестовая остановка — Центр', type,
+ stops: [{ id: 1, name: stop.name, sequence: 0, offsetMinutes: 0 }, ...targets],
+ schedules: ['weekday', 'saturday', 'sunday'].map(dayType => ({ dayType, validFrom: null, validUntil: null,
+ trips: [{ arrivalSeconds: seconds, originStopId: 1, endStopId: 903, originTime: `${String(Math.floor(seconds / 3600) % 24).padStart(2, '0')}:${String(Math.floor(seconds % 3600 / 60)).padStart(2, '0')}`,
+ destination: 'Центр', note: 'Тестовые данные', daysOfWeek: [], exceptDaysOfWeek: [] }] })) };
+ }), calendarYears: [2026, 2027], holidays: [], updatedAt: new Date(now).toISOString(), expiresAt: new Date(now + 86400000).toISOString() };
+ const board = { cityId: 1, cityName: 'Полоцк', stop, routes: t.routes.map(({ stops, schedules, ...route }) => route),
+ departures: timetableDepartures(t, localDate(now)).filter(trip => Date.parse(trip.scheduledAt) + 60000 > now), alerts: [], alertsAvailable: true, partial: false, serviceDate: localDate(now), updatedAt: new Date(now).toISOString(), serverTime: new Date(now).toISOString(), mode: 'live' };
+ const nextHour = Math.ceil(now / 3600000) * 3600000;
+ const weather = { cityId: 1, stopId: 1, updatedAt: new Date(now).toISOString(), observedAt: new Date(now).toISOString(), temperature: 11, apparentTemperature: 10, code: 3, isDay: true, windSpeed: 3,
+ hourly: [0, 1, 2].map(i => ({ at: new Date(nextHour + i * 3600000).toISOString(), temperature: 12 - i, code: i ? 61 : 3, precipitationProbability: 80 - i * 10 })) };
+ return { t, board, weather, settings: { ...defaultSettings, stopId: 1, holdNear: false, nightMode: 'off', places: targets.map(s => ({ stopId: s.id, name: s.name })) } };
+}
+const client = { config: () => ({ cityId: 1, cityName: 'Полоцк', stopName: stop.name, defaultStopId: 1, configured: true, refreshSeconds: 20, timeZone: 'Europe/Minsk', passengerBaseUrl: 'https://pwa.ridebus.by/' }),
+ stops: async () => [stop, ...targets.map(s => ({ id: s.id, name: s.name, direction: 'тест' }))], board: async () => resources().board, timetable: async () => resources().t, coordinates: async () => null };
+const app = express();
+app.get('/__fixture', (req, res) => {
+ if (['bus', 'minibus'].includes(req.query.first)) { firstType = req.query.first; started = Date.now(); }
+ const payload = JSON.stringify(resources()).replaceAll('<', '\\u003c');
+ res.type('html').send(`Пассажирские блоки · тестовый стенд Фиктивное расписание и погода. Тарифы — подтверждённые пользователем значения для Полоцка. Рабочая БД не используется.
Ближайший: ${firstType === 'bus' ? 'автобус' : 'маршрутка'}. Важные места здесь только для проверки.
Открыть тестовый пример `);
+});
+app.use(createApp(client, { weather: async () => resources().weather }, createDeviceBridge({ enabled: false })));
+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(5194, '127.0.0.1', resolve));
+console.log('Passenger UI fixture http://127.0.0.1:5194/__fixture');
diff --git a/test/information.test.mjs b/test/information.test.mjs
index 7be0e46..73eccc3 100644
--- a/test/information.test.mjs
+++ b/test/information.test.mjs
@@ -3,14 +3,20 @@ import assert from 'node:assert/strict';
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 off = { ...defaultSettings, infoServiceEnabled: false, infoWeatherEnabled: false, infoQrEnabled: false, infoPlacesEnabled: false, infoFareEnabled: false };
test('disabled information blocks are omitted without leaving gaps in the cycle', () => {
- assert.deepEqual(informationBlocks(defaultSettings).map(block => block.id), ['service', 'weather', 'qr']);
+ assert.deepEqual(informationBlocks(defaultSettings).map(block => block.id), ['service', 'weather', 'qr', 'fare']);
assert.deepEqual(informationBlocks({ ...off, infoQrEnabled: true }).map(block => block.id), ['qr']);
- assert.deepEqual(informationBlocks({ ...defaultSettings, infoWeatherEnabled: false }).map(block => block.id), ['service', 'qr']);
+ assert.deepEqual(informationBlocks({ ...defaultSettings, infoWeatherEnabled: false }).map(block => block.id), ['service', 'qr', 'fare']);
assert.deepEqual(informationBlocks(off), []);
});
+test('unconfigured or unavailable passenger cards do not leave empty scenes', () => {
+ assert.deepEqual(informationBlocks(defaultSettings, { places: false, fare: false }).map(b => b.id), ['service', 'weather', 'qr']);
+ assert.deepEqual(boardScenes({ ...off, infoFareEnabled: true }, false, false, { places: false, fare: false }), ['departures']);
+ const configured = { ...defaultSettings, places: [{ stopId: 2, name: 'Вокзал' }] };
+ assert.equal(informationBlocks(configured, { places: true, fare: true }).length, 5);
+});
test('all blocks off keeps departures, retains optional advertising, and still surfaces alerts', () => {
assert.deepEqual(boardScenes(off, false, false), ['departures']);
assert.deepEqual(boardScenes({ ...off, adsEnabled: true }, false, false), ['departures', 'ad']);
diff --git a/test/passenger-guide.test.mjs b/test/passenger-guide.test.mjs
new file mode 100644
index 0000000..ad32fe7
--- /dev/null
+++ b/test/passenger-guide.test.mjs
@@ -0,0 +1,103 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { passengerGuide } from '../src/lib/passenger-guide.ts';
+import { precipitationHint } from '../src/lib/weather-hint.ts';
+import { defaultSettings } from '../src/lib/storage.ts';
+import { validPlaces, validFareRules, validFareByType } from '../src/lib/passenger-settings.mjs';
+
+const instant = text => Date.parse(`2026-10-11T${text}:00+03:00`);
+const stops = [1, 2, 3, 4, 5].map((id, i) => ({ id, name: `Остановка ${id}`, sequence: i, offsetMinutes: [0, 5, 20, 35, 45][i] }));
+const trip = (time, endStopId = 5, originStopId = 1) => ({ arrivalSeconds: time, originStopId, endStopId, originTime: '12:00', destination: `Конечная ${endStopId}`, note: '', daysOfWeek: [], exceptDaysOfWeek: [] });
+function bundle(now, schedules = { sunday: [trip(12 * 3600 + 5 * 60, 3), trip(12 * 3600 + 10 * 60)] }) {
+ return { version: 1, cityId: 1, cityName: 'Полоцк', stop: { id: 2, name: 'Текущая', direction: '' }, routes: [{ id: 7, number: '7', name: 'Маршрут', type: 'bus', stops,
+ schedules: Object.entries(schedules).map(([dayType, trips]) => ({ dayType, trips, validFrom: null, validUntil: null })) }], holidays: [], calendarYears: [2026, 2027],
+ updatedAt: new Date(now - 60000).toISOString(), expiresAt: new Date(now + 7 * 86400000).toISOString() };
+}
+const prefs = { ...defaultSettings, stopId: 2, places: [{ stopId: 3, name: 'Больница' }, { stopId: 4, name: 'Вокзал' }] };
+const data = (now, alerts = []) => ({ cityId: 1, stop: { id: 2, name: 'Текущая' }, routes: [{ id: 7, number: '7' }], mode: 'live', serviceDate: '2026-10-11', updatedAt: new Date(now).toISOString(), departures: [], alerts, alertsAvailable: true });
+const cancellation = (date, time) => ({ id: 'cancel', type: 'cancellation', routeIds: [7], routeNumbers: ['7'], title: 'Отмена', message: '', startAt: null, endAt: null,
+ cancellations: [{ routeNumber: '7', stopId: 2, serviceDate: date, time }] });
+
+test('important places choose the next direct trip that actually reaches the target, including shortened trips', () => {
+ const now = instant('12:00'), guide = passengerGuide(prefs, bundle(now), data(now), now, 1, true);
+ assert.equal(guide.places[0].departure.scheduledTime, '12:05');
+ assert.equal(guide.places[0].minutes, 15); assert.equal(guide.places[0].arrivalAt, '2026-10-11T09:20:00.000Z');
+ assert.equal(guide.places[1].departure.scheduledTime, '12:10'); assert.equal(guide.places[1].minutes, 30);
+ assert.equal(guide.places[1].arrivalAt, '2026-10-11T09:40:00.000Z');
+});
+test('confirmed cancellations are excluded and route filters, reverse paths and missing offsets remain honest', () => {
+ const now = instant('12:00'), b = bundle(now);
+ assert.equal(passengerGuide(prefs, b, data(now, [cancellation('2026-10-11', '12:05')]), now, 1, true).places[0].departure.scheduledTime, '12:10');
+ assert.equal(passengerGuide({ ...prefs, routeFilter: '999' }, b, data(now), now, 1, true).places[0].departure, null);
+ const reverse = bundle(now, { sunday: [trip(12 * 3600 + 15 * 60, 1, 5)] });
+ const reversed = passengerGuide({ ...prefs, places: [{ stopId: 1, name: 'Начало' }, { stopId: 3, name: 'Позади' }] }, reverse, data(now), now, 1, true);
+ assert.equal(reversed.places[0].minutes, 5); assert.equal(reversed.places[1].departure, null);
+ const missing = structuredClone(b); missing.routes[0].stops[2].offsetMinutes = null;
+ const unknown = passengerGuide(prefs, missing, data(now), now, 1, true).places[0];
+ assert.equal(unknown.departure.scheduledTime, '12:05'); assert.equal(unknown.minutes, null); assert.equal(unknown.arrivalAt, null);
+});
+test('old or foreign caches do not produce place guesses, and type fares never cross city boundaries', () => {
+ const now = instant('12:00'), b = bundle(now);
+ const stale = { ...b, expiresAt: new Date(now - 1).toISOString() };
+ assert.deepEqual(passengerGuide(prefs, stale, data(now), now, 1, true).places, []);
+ assert.deepEqual(passengerGuide(prefs, { ...b, cityId: 2 }, data(now), now, 1, true).places, []);
+ const legacy = structuredClone(b); delete legacy.routes[0].stops;
+ assert.deepEqual(passengerGuide(prefs, legacy, data(now), now, 1, true).places, []);
+ assert.equal(passengerGuide(prefs, b, data(now), now, 2, true).fare, null);
+ assert.equal(passengerGuide({ ...prefs, infoPlacesEnabled: false }, b, data(now), now, 1, true).places.length, 0);
+});
+test('arrival at an important place keeps its actual date when the trip crosses midnight', () => {
+ const now = instant('23:40'), b = bundle(now, { sunday: [trip(23 * 3600 + 55 * 60)] });
+ const place = passengerGuide(prefs, b, data(now), now, 1, true).places[0];
+ assert.equal(place.departure.scheduledTime, '23:55'); assert.equal(place.arrivalAt, '2026-10-11T21:10:00.000Z');
+ assert.equal(localDay(place.arrivalAt), '2026-10-12');
+});
+function localDay(iso) { return new Date(Date.parse(iso) + 10800000).toISOString().slice(0, 10); }
+test('fares follow the nearest available transport type and a dated route override takes priority', () => {
+ const now = instant('12:00'), b = bundle(now);
+ assert.equal(passengerGuide(prefs, b, data(now), now, 1, true).fare.price, '1,05 BYN');
+ const mini = structuredClone(b); mini.routes[0].type = 'minibus';
+ assert.equal(passengerGuide(prefs, mini, data(now), now, 1, true).fare.price, '2,10 BYN');
+ const override = { routeId: 7, price: 'Особый тариф', payment: 'Подтверждённый способ', checkedAt: '2026-10-11' };
+ assert.equal(passengerGuide({ ...prefs, fareRules: [override] }, mini, data(now), now, 1, true).fare.price, 'Особый тариф');
+ assert.equal(passengerGuide({ ...prefs, fareRules: [{ ...override, checkedAt: '2026-10-12' }] }, b, data(now), now, 1, true).fare.price, '1,05 BYN');
+ assert.equal(passengerGuide({ ...prefs, infoFareEnabled: false }, b, data(now), now, 1, true).fare, null);
+ assert.equal(passengerGuide({ ...prefs, fareByType: [], fareRules: [] }, b, data(now), now, 1, true).fare, null);
+});
+test('last-trip notice recognizes an overnight closing gap but ignores a daytime gap and the opposite direction', () => {
+ const now = Date.parse('2026-10-10T23:10:00+03:00');
+ const b = bundle(now, { saturday: [trip(23 * 3600 + 20 * 60), trip(23 * 3600 + 30 * 60, 1, 5)], sunday: [trip(5 * 3600 + 15 * 60)] });
+ const guide = passengerGuide(prefs, b, null, now, 1, false);
+ assert.ok(guide.lastTripId); assert.ok(guide.lastTripId.includes('2026-10-10'));
+ assert.equal(passengerGuide({ ...prefs, lastTripNoticeEnabled: false }, b, null, now, 1, false).lastTripId, null);
+ assert.equal(passengerGuide(prefs, b, null, now - 21 * 60000, 1, false).lastTripId, null);
+ const day = instant('12:00');
+ assert.equal(passengerGuide(prefs, bundle(day, { sunday: [trip(12 * 3600 + 5 * 60), trip(15 * 3600 + 30 * 60)] }), null, day, 1, false).lastTripId, null);
+});
+test('closing notice handles both service-day and calendar-day after-midnight tails, without closing continuous overnight service', () => {
+ const now = instant('00:00');
+ for (const afterMidnight of [true, false]) {
+ const b = bundle(now, { saturday: [trip(23 * 3600 + 20 * 60), ...(afterMidnight ? [trip(86400 + 15 * 60)] : [])],
+ sunday: [...(!afterMidnight ? [trip(15 * 60)] : []), trip(5 * 3600 + 15 * 60)] });
+ assert.ok(passengerGuide(prefs, b, null, now, 1, false).lastTripId, String(afterMidnight));
+ }
+ const continuous = bundle(now, { saturday: [trip(23 * 3600 + 20 * 60)], sunday: [trip(15 * 60), trip(2 * 3600), trip(4 * 3600), trip(5 * 3600 + 15 * 60)] });
+ assert.equal(passengerGuide(prefs, continuous, null, now, 1, false).lastTripId, null);
+});
+test('weather hint preserves zero probability and suppresses expired or missing near-term forecast', () => {
+ const now = instant('12:10'), weather = { cityId: 1, stopId: 2, updatedAt: new Date(now).toISOString(), observedAt: new Date(now).toISOString(), temperature: 11, apparentTemperature: 10,
+ code: 3, isDay: true, windSpeed: 3, hourly: [{ at: '2026-10-11T10:00:00.000Z', temperature: 11, code: 3, precipitationProbability: 0 }] };
+ assert.equal(precipitationHint(weather, now), 0);
+ assert.equal(precipitationHint({ ...weather, hourly: [{ ...weather.hourly[0], precipitationProbability: 80 }] }, now), 80);
+ assert.equal(precipitationHint({ ...weather, updatedAt: new Date(now - 4 * 3600000).toISOString() }, now), null);
+ assert.equal(precipitationHint({ ...weather, hourly: [] }, now), null);
+ assert.equal(precipitationHint(weather, now - 2 * 3600000), null);
+});
+test('passenger settings reject duplicate targets, malformed dates and unbounded or unknown tariff fields', () => {
+ assert.equal(validPlaces(prefs.places), true); assert.equal(validFareByType(prefs.fareByType), true);
+ assert.equal(validPlaces([prefs.places[0], prefs.places[0]]), false);
+ assert.equal(validPlaces([{ stopId: 3, name: ' ' }]), false);
+ for (const patch of [{ checkedAt: '2026-02-30' }, { payment: '' }, { price: 'x'.repeat(41) }, { secret: 'bad' }]) {
+ assert.equal(validFareRules([{ routeId: 7, price: '1 BYN', payment: 'Наличные', checkedAt: '2026-10-11', ...patch }]), false);
+ }
+});
diff --git a/test/storage.test.mjs b/test/storage.test.mjs
index c228fdb..3e2b185 100644
--- a/test/storage.test.mjs
+++ b/test/storage.test.mjs
@@ -2,6 +2,29 @@ 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('transliteration is opt-in for old settings and persists as a boolean', () => {
+ const values = new Map([['ridebus-board-settings-v1', '{}']]);
+ globalThis.localStorage = { getItem: key => values.get(key) || null, setItem: (key, value) => values.set(key, value) };
+ const old = readSettings();
+ assert.equal(old.journeyTranslitEnabled, false);
+ saveSettings({ ...old, journeyTranslitEnabled: true });
+ assert.equal(readSettings().journeyTranslitEnabled, true);
+ saveSettings({ ...readSettings(), journeyTranslitEnabled: false });
+ assert.equal(readSettings().journeyTranslitEnabled, false);
+ values.set('ridebus-board-settings-v1', JSON.stringify({ journeyTranslitEnabled: 'true' }));
+ assert.equal(readSettings().journeyTranslitEnabled, false);
+});
+test('passenger preferences migrate with confirmed city-bound fares and preserve operator overrides', () => {
+ const values = new Map(); globalThis.localStorage = { getItem:key=>values.get(key)||null, setItem:(key,value)=>values.set(key,value) };
+ const old = readSettings(); assert.deepEqual(old.places, []); assert.deepEqual(old.fareRules, []);
+ assert.equal(old.fareByType[0].price, '1,05 BYN'); assert.equal(old.fareByType[0].cityId, 1);
+ const edited = { ...old, places: [{stopId:28,name:'Вокзал'}], fareRules: [{routeId:7,price:'Особый тариф',payment:'Подтверждённый способ',checkedAt:'2026-10-11'}],
+ infoPlacesEnabled:false, infoFareEnabled:false, lastTripNoticeEnabled:false, weatherHintEnabled:false, fareByType:[] };
+ saveSettings(edited); assert.deepEqual(readSettings(),edited);
+ values.set('ridebus-board-settings-v1',JSON.stringify({places:[{stopId:-1,name:'Неверно'}],fareByType:null}));
+ assert.deepEqual(readSettings().places,[]);assert.deepEqual(readSettings().fareByType,[]);
+});
+
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);
diff --git a/test/transliteration.test.mjs b/test/transliteration.test.mjs
new file mode 100644
index 0000000..fce0271
--- /dev/null
+++ b/test/transliteration.test.mjs
@@ -0,0 +1,20 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { transliterateStopName } from '../src/lib/transliteration.ts';
+
+test('stop names use readable Latin spelling without translating or losing punctuation', () => {
+ for (const [name, latin] of [
+ ['Пушкинская', 'Pushkinskaya'],
+ ['Швейная фабрика', 'Shveynaya fabrika'],
+ ['Больница (Ксты)', 'Bolnitsa (Ksty)'],
+ ['Педагогический колледж', 'Pedagogicheskiy kolledzh'],
+ ['Боровуха-3 · Школа №9', 'Borovukha-3 · Shkola №9'],
+ ['Ёлочная / Южная / Щорса', 'Yolochnaya / Yuzhnaya / Shchorsa'],
+ ['КСТЫ — ТЦ «ЕВРООПТ»', 'KSTY — TTS «EVROOPT»'],
+ ['Вуліца Янкі Купалы', 'Vulitsa Yanki Kupaly'],
+ ['Уручча · Зялёны Луг', 'Uruchcha · Zyalyony Lug'],
+ ['Мастоў · імя Янкі Купалы', 'Mastou · imya Yanki Kupaly'],
+ ['RideBus PWA (2026)', 'RideBus PWA (2026)'],
+ ['', ''],
+ ]) assert.equal(transliterateStopName(name), latin, name);
+});
diff --git a/tsconfig.json b/tsconfig.json
index aaa30a8..b70eaf1 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,7 +1,7 @@
{
"compilerOptions": {
"target": "ES2022", "lib": ["ES2022", "DOM", "DOM.Iterable"],
- "module": "ESNext", "moduleResolution": "Bundler", "jsx": "react-jsx",
+ "module": "ESNext", "moduleResolution": "Bundler", "jsx": "react-jsx", "allowImportingTsExtensions": true,
"strict": true, "noEmit": true, "skipLibCheck": true,
"allowJs": true, "esModuleInterop": true, "resolveJsonModule": true,
"noUnusedLocals": true, "noUnusedParameters": true