feat: add passenger information and settings components
- Introduced `PassengerInfo.tsx` for displaying trip details, including upcoming trips and fare information. - Added `PassengerSettings.tsx` for managing user-defined important places and fare settings. - Implemented validation functions for passenger settings in `passenger-settings.mjs`. - Created utility functions for transliterating stop names in `transliteration.ts`. - Developed weather hint functionality in `weather-hint.ts` to provide precipitation probabilities. - Established a mock server for testing passenger-related features in `passenger-server.mjs`. - Added comprehensive tests for passenger guide logic and transliteration functionality.
15
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, не обращается к рабочей БД и не изменяет настройки основного табло.
|
||||
|
||||
BIN
artifacts/journey-translit-960x540.png
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
artifacts/journey-translit-street-light-960x540.png
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
artifacts/passenger-admin-fields.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
artifacts/passenger-fare-minibus-960x540.jpg
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
artifacts/passenger-places-390x844.png
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
artifacts/passenger-places-960x540.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
artifacts/passenger-weather-960x540.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
artifacts/passenger-weather-street-light-960x540.png
Normal file
|
After Width: | Height: | Size: 50 KiB |
30
artifacts/verification-journey-transliteration.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# Схема ближайшего рейса и транслит
|
||||
|
||||
Проверено 11 октября 2026 года.
|
||||
|
||||
## Изменения
|
||||
|
||||
- Убраны повторные номер, направление и время отправления из карточки схемы. Ближайший рейс остаётся в постоянной нижней панели.
|
||||
- Карточка и её переключатель называются «Дальше по ближайшему рейсу».
|
||||
- Убран заголовок «Информация» и занимаемый им отступ. Вкладки компактной компоновки и заголовок уведомлений сохранены.
|
||||
- Дополнительная строка латиницей появляется над основным названием при включённом `journeyTranslitEnabled`. Основное название сохраняется, уже латинские названия не дублируются. Это читаемая транслитерация, без перевода.
|
||||
- Настройка есть локально в «Экраны» и в админке устройств `polotsk-transit-api`. Старые настройки без нового поля используют `false`; миграция SQL не нужна.
|
||||
|
||||
## Проверки
|
||||
|
||||
- `ridebus-board`: TypeScript + Vite build; 84 теста пройдены, включая транслитерацию, сохранение настроек и совместимость удалённых конфигураций.
|
||||
- `polotsk-transit-api`: frontend build; 8 тестов сервиса устройств и 1 тест HTTP API пройдены. Новый флаг сохраняется при удалённом обновлении и возвращается в конфигурации устройства. Повреждённые значения отклоняются.
|
||||
- Браузер: 960×540, 879×593, 855×723, 390×844, 1280×800. На узкой и широкой карточке нет вертикального переполнения; латинская строка находится выше основного названия. На широкой компоновке нет пустого места от скрытого общего заголовка.
|
||||
- Обе темы и оба профиля проверены на 960×540. Включение и отключение флага сохраняются после перезагрузки.
|
||||
- Тестовые счётчики на переходах 1→2, 2→3, 3→4 с транслитом: в обоих профилях ноль изменений размеров блока названия и ноль записей размера шрифта за 1200 мс. Два заранее подготовленных слоя текста продолжают двигаться через transform/opacity. Это проверка работы DOM, а не измерение FPS на телевизоре.
|
||||
|
||||
## Ограничения
|
||||
|
||||
Локальный API стенда `127.0.0.1:5193` в проверочном браузере возвращал `ERR_BLOCKED_BY_CLIENT`. Сервер отдельно вернул валидный ответ и полное расписание с HTTP 200; визуальные проверки сделаны на встроенных демонстрационных данных. Xiaomi Mi Box 4 в этом запуске не проверялся.
|
||||
|
||||
## Примеры
|
||||
|
||||
- `journey-translit-960x540.png`: тёмная тема, ТВ-профиль, демонстрационные данные.
|
||||
- `journey-translit-street-light-960x540.png`: светлая тема, уличный профиль, демонстрационные данные.
|
||||
|
||||
Для удалённого переключения нужно обновить табло, backend и frontend админки.
|
||||
28
artifacts/verification-passenger-info.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# Пассажирские блоки · 11 октября 2026
|
||||
|
||||
Реализованы «До важных мест», напоминание о последнем рейсе перед ночным перерывом, вероятность осадков в ближайший час и оплата ближайшего доступного маршрута. Изменены Board и backend/frontend `/Users/xtimms/Projects/polotsk-transit-api`. Места в рабочей конфигурации по умолчанию пусты. Пользователь подтвердил тарифы для Полоцка: автобус 1,05 BYN, маршрутка 2,10 BYN, QR-кодом или наличными; дата подтверждения 2026-10-11 фиксирована.
|
||||
|
||||
## Проверки кода
|
||||
|
||||
- `npm test`: 96/96, включая короткие/обратные рейсы, подтверждённые отмены, фильтр маршрута, неизвестные временные смещения, прибытие на следующий день, ночные продолжения в обоих форматах дня обслуживания, дневные перерывы, выбор тарифа и изоляцию городов.
|
||||
- `npm run build` Board: TypeScript + Vite успешно.
|
||||
- `node --test test/boardDevices.test.js test/boardDevicesApi.test.js` в backend: 11/11. Проверены ограничения полей, даты, чужой город/маршрут, нормализация старых настроек, сохранение пустых массивов и авторизация существующего протокола.
|
||||
- `npm run build` frontend админки: успешно; прежние предупреждения о Browserslist/размере bundle остаются.
|
||||
- `git diff --check` обоих проектов: без ошибок.
|
||||
|
||||
## Проверки браузера
|
||||
|
||||
Использован production build на явном локальном стенде `test/fixtures/passenger-server.mjs`, отдельный origin 5194, фиктивные остановки/расписание/погода. Настройки основного origin 5180 и рабочая БД не изменялись.
|
||||
|
||||
- Подтверждён действительный DOM viewport 960×540, 879×593, 855×723, 390×844 и 1440×900. Нет переполнения страницы и видимых новых карточек. На 1440×900 три карточки проверены одновременно; строки важных мест также не переполняются.
|
||||
- На 960×540 название места имеет размер около 43 px, тариф — около 82 px. Места разбиваются на три страницы; наблюдался автоматический переход к следующему месту. Число страниц удлиняет сцену до полного обзора. На высоком узком экране все три коротких тестовых места помещаются сразу.
|
||||
- Ближайший автобус показывает 1,05 BYN; после выбора маршрутки через видимый интерфейс стенда — 2,10 BYN. Видны способы оплаты и фиксированная дата подтверждения.
|
||||
- Погода показывает 80% в ближайший час. После локального выключения подсказка исчезает, после повторного включения появляется. Светлая тема и профиль «Уличное табло» проверены на 960×540.
|
||||
- Выключение важных мест сохраняется после перезагрузки и убирает вкладку. Данные мест и тарифов доступны в меню «Экраны». Кнопка «Ниже» прокрутила расширенное меню (DOM scrollTop 1099).
|
||||
- На стенде `test/fixtures/device-flow-server.mjs` устройство подключено кодом 0246; сохранены места, оба тарифа и переключатели. Реальный локальный HTTP-протокол передал цену устройству. Админка показала «Настройки применены» для v1, v2 (оплата выключена) и v3 (восстановлена); выключенная вкладка исчезла с устройства. Используется репозиторий в памяти, без PostgreSQL.
|
||||
- Устройство без полного расписания не показывает выбранное важное место, поскольку маршрут до него не подтверждён. Карточка оплаты продолжает работать по ближайшему доступному рейсу.
|
||||
- В консоли тестового приложения ошибок/предупреждений не обнаружено.
|
||||
|
||||
Скриншоты: `passenger-places-960x540.png`, `passenger-weather-960x540.png`, `passenger-places-390x844.png`, `passenger-fare-minibus-960x540.jpg`, `passenger-weather-street-light-960x540.png`. Данные и время на них тестовые. Захват in-app browser через fullPage иногда пропускает неизменившиеся compositor layers; снимок не заменяет DOM-проверку заполнения/границ.
|
||||
|
||||
Физический Mi Box / Hi Browser, FPS устройства, PostgreSQL integration test и развёртывание на рабочем сервере в этой проверке не выполнялись. Настройки хранятся в существующем JSON, новая миграция SQL не требуется; для удалённого управления обновляются оба проекта.
|
||||
50
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
|
||||
</div>;
|
||||
}
|
||||
|
||||
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 ? <div className="long-wait-time"><span>{localDate(Date.parse(first.scheduledAt)) > localDate(now) ? 'Завтра в' : 'Сегодня в'}</span><ScheduledTime departure={first} now={now} showDay={false} />{first.status === 'notice' && <span className="departure-change">Есть изменения</span>}</div> : <DepartureStatus departure={first} now={now} large />}
|
||||
</div> : <div className="empty-departures" data-cancelled={departures.length > 0}><BusFront size={46} /><h2>{departures.length ? 'Ближайшие рейсы отменены' : loading ? 'Получаем расписание' : !data ? 'Расписание недоступно' : fresh ? 'На сегодня рейсов больше нет' : 'Сохранённые рейсы закончились'}</h2>
|
||||
<p>{departures.length ? 'Подробности — в информации о движении' : loading ? 'Ближайший транспорт появится здесь' : error === 'api_not_configured' ? 'Подключите RideBus API или включите деморежим в настройках' : !data || !fresh ? 'Ожидаем подключения к RideBus' : 'Новые рейсы появятся с началом движения'}</p></div>}
|
||||
{first && first.id === lastTripId && <LastTripNotice number={first.routeNumber} />}
|
||||
<div className="next-heading"><span className="eyebrow">{!first && departures.length ? 'ОТМЕНЁННЫЕ РЕЙСЫ' : remaining.some(item => item.status === 'cancelled') ? 'ДРУГИЕ РЕЙСЫ' : 'СЛЕДОМ ПРИБУДУТ'}</span>{visible.pages > 1 ? <span className="page-indicator">{visible.start + 1}{visible.items.length > 1 ? `–${visible.start + visible.items.length}` : ''} из {visible.count}</span> : <ArrowDown size={22} />}</div>
|
||||
<div ref={table} className="departure-table" data-rows={rowCount} data-paged={visible.pages > 1} style={{ '--rows': rows, '--row-height': `${tableHeight / rows}px` } as CSSProperties}>
|
||||
{visible.items.map((departure, index) => <article className="departure-row" data-status={departure.status} key={`position-${index}`}>
|
||||
@@ -104,17 +110,19 @@ export function DeparturesScreen({ data, departures, now, alerts, loading, rowCo
|
||||
</div>;
|
||||
}
|
||||
|
||||
function InfoScreen({ data, alerts, fresh, seconds, paused, page, onPage, config, weather, now, settings, journey, onJourneyPages }: { data: BoardData | null; alerts: Alert[]; fresh: boolean; seconds: number; paused: boolean; page: number; onPage: (page: number) => void;
|
||||
config: Config; weather: ReturnType<typeof useWeather>; now: number; settings: Settings; journey: Omit<ComponentProps<typeof NextStops>, '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<typeof useWeather>; now: number; settings: Settings; journey: Omit<ComponentProps<typeof NextStops>, 'active' | 'onPages'>; onJourneyPages: (pages: number) => void;
|
||||
guide: PassengerGuide; onPlacePages: (pages: number) => void }) {
|
||||
const panel = useRef<HTMLElement>(null);
|
||||
const grid = useRef<HTMLDivElement>(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 <section ref={panel} className="info-screen" data-compact={compact} data-cards={!alerts.length} data-blocks={blocks.length}
|
||||
return <section ref={panel} className="info-screen" data-compact={compact} data-cards={!alerts.length} data-blocks={blocks.length} data-many-blocks={blocks.length > 3}
|
||||
style={{ '--info-columns': blocks.length, '--info-tabs-width': `${blocks.length > 1 ? blocks.length * 100 : 0}px` } as CSSProperties} aria-label="Информация для пассажиров">
|
||||
<div className="info-intro"><h2>{alerts.length ? 'Изменения движения' : 'Информация'}</h2>
|
||||
{!alerts.length && blocks.length > 1 && <nav className="information-tabs" aria-label="Блоки информации">{blocks.map((block, index) => <button key={block.id} aria-current={activeBlock === block.id ? 'page' : undefined} onClick={() => onPage(index)}>{block.name}</button>)}</nav>}</div>
|
||||
{(alerts.length > 0 || blocks.length > 1) && <div className="info-intro" data-tabs-only={!alerts.length}>
|
||||
{alerts.length > 0 && <h2>Изменения движения</h2>}
|
||||
{!alerts.length && blocks.length > 1 && <nav className="information-tabs" aria-label="Блоки информации">{blocks.map((block, index) => <button key={block.id} aria-current={activeBlock === block.id ? 'page' : undefined} onClick={() => onPage(index)}>{block.name}</button>)}</nav>}</div>}
|
||||
{alerts.length ? <div className="info-notices">{alerts.map(alert => <AlertCard key={alert.id} alert={alert} />)}</div> : <div ref={grid} className="info-grid">
|
||||
{blocks.map(block => <section key={block.id} className={`info-card ${block.id}-info`} data-active={activeBlock === block.id}>
|
||||
{block.id === 'service' && <NextStops key={journey.departure?.id || 'none'} {...journey} active={!compact || activeBlock === 'service'} onPages={onJourneyPages} />}
|
||||
{block.id === 'weather' && <WeatherCard data={weather.data} now={now} loading={weather.loading} failed={weather.failed} demo={data?.mode === 'demo'} />}
|
||||
{block.id === 'weather' && <WeatherCard data={weather.data} now={now} loading={weather.loading} failed={weather.failed} demo={data?.mode === 'demo'} hintEnabled={settings.weatherHintEnabled} />}
|
||||
{block.id === 'qr' && <PassengerQr config={config} stop={data?.stop} />}
|
||||
{block.id === 'places' && <PlacesCard trips={guide.places} now={now} paused={paused} settings={settings} active={!compact || activeBlock === 'places'} onPages={onPlacePages} />}
|
||||
{block.id === 'fare' && guide.fare && <FareCard fare={guide.fare} />}
|
||||
</section>)}
|
||||
</div>}
|
||||
{!alerts.length && (data?.alertsAvailable === false || !fresh) && <div className="info-status"><Shield /><span>Предупреждения сейчас недоступны</span></div>}
|
||||
@@ -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 && <div className="managed-offline" role="status">Нет связи с управлением · сохранённые настройки</div>}
|
||||
<main className="scene-container"><div className="scene" key={scene} data-scene={scene}>
|
||||
{night.active && night.first && <NightScreen first={night.first} completedToday={night.completedToday} now={now} routeFiltered={Boolean(settings.routeFilter)} alerts={alerts} paused={paused || settingsOpen} />}
|
||||
{!night.active && scene === 'departures' && <DeparturesScreen data={data} departures={departures} now={now} alerts={alerts} loading={loading} rowCount={settings.rowCount} fresh={fresh} error={error} paused={paused || settingsOpen} settings={settings} />}
|
||||
{!night.active && scene === 'info' && <InfoScreen data={data} alerts={alerts} fresh={fresh} seconds={seconds} paused={paused || settingsOpen} page={infoPage} onPage={setInfoPage} config={config} weather={weather} now={now} settings={settings} onJourneyPages={setJourneyPages}
|
||||
journey={{ departure: first, journey, loading: timetable.loading || loading, now, paused: paused || settingsOpen, settings }} />}
|
||||
{!night.active && scene === 'departures' && <DeparturesScreen data={data} departures={departures} now={now} alerts={alerts} loading={loading} rowCount={settings.rowCount} fresh={fresh} error={error} paused={paused || settingsOpen} settings={settings} lastTripId={guide.lastTripId} />}
|
||||
{!night.active && scene === 'info' && <InfoScreen data={data} alerts={alerts} fresh={fresh} seconds={seconds} paused={paused || settingsOpen} page={infoPage} onPage={value => { setInfoPage(value); setStartedAt(Date.now()); }} 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' && <section className="ad-screen"><span className="eyebrow">РЕКЛАМА · ПРЕДПРОСМОТР</span><h2>{settings.adTitle}</h2><p>{settings.adText}</p><div className="ad-signature">Время для нового.<ArrowUpRight /></div></section>}
|
||||
</div></main>
|
||||
{!night.active && scene !== 'departures' && <div className="persistent-next"><span className="eyebrow">БЛИЖАЙШИЙ РЕЙС</span>{first ? <><RouteBadge number={first.routeNumber} filled /><div className="persistent-destination"><strong>{first.destination}</strong><ScheduledTime departure={first} now={now} /></div><Countdown departure={first} now={now} /></> : <span>Нет доступных рейсов</span>}</div>}
|
||||
{!night.active && scene !== 'departures' && <div className="persistent-next"><span className="eyebrow">БЛИЖАЙШИЙ РЕЙС</span>{first ? <><RouteBadge number={first.routeNumber} filled /><div className="persistent-destination"><strong>{first.destination}</strong><ScheduledTime departure={first} now={now} />{guide.lastTripId === first.id && <LastTripNotice number={first.routeNumber} compact />}</div><Countdown departure={first} now={now} /></> : <span>Нет доступных рейсов</span>}</div>}
|
||||
<footer className="board-footer"><div className="cycle-progress"><span style={{ transform: `scaleX(${held || paused || settingsOpen || scenes.length < 2 ? 0 : progress})` }} /></div>
|
||||
<div className="footer-main"><nav className="scene-navigation" aria-label="Экраны табло">{scenes.map((item, index) => <button key={item} onClick={() => goTo(item)} className={scene === item ? 'active' : ''} aria-label={`0${index + 1} ${night.active ? 'Ночной режим' : sceneNames[item]}`} title={night.active ? 'Ночной режим' : sceneNames[item]} aria-current={scene === item ? 'page' : undefined}><span className="scene-index">0{index + 1}</span><span>{night.active ? 'Ночной режим' : sceneNames[item]}</span></button>)}</nav>
|
||||
<div className="footer-status">{!fresh && !settings.demo && <WifiOff />}<span>{settings.demo ? 'Демонстрация' : held ? 'Приоритет транспорта' : `Обновлено в ${updateLabel}`}{data?.partial ? ' · часть рейсов недоступна' : ''}{offlineSaved === false ? ' · сохранение недоступно' : ''}</span></div>
|
||||
|
||||
@@ -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<HTMLDivElement>(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}>
|
||||
<span className="journey-line" aria-hidden="true" style={{ transform: `scaleX(${scale})` }} />
|
||||
<ol className="journey-rail" aria-label="Следующие остановки после посадки">
|
||||
{journey.stops.map((stop, index) => <li className="journey-station" key={stop.id} data-focused={index === focus} data-before={index < focus} data-after={index > focus} aria-current={index === focus ? 'step' : undefined}>
|
||||
{journey.stops.map((stop, index) => {
|
||||
const latinName = translit ? transliterateStopName(stop.name) : stop.name;
|
||||
return <li className="journey-station" key={stop.id} data-focused={index === focus} data-before={index < focus} data-after={index > focus} aria-current={index === focus ? 'step' : undefined}>
|
||||
<span className="journey-duration" aria-label={stop.minutes === null ? 'Время пути неизвестно' : `В пути ${stop.minutes} мин по расписанию`}>
|
||||
{stop.minutes === null ? '—' : stop.minutes === 0 ? <><span><1</span><small>мин</small></> : durationParts(stop.minutes).map(part => <span key={part.unit}><NumericText value={part.value} /><small>{part.unit}</small></span>)}
|
||||
</span>
|
||||
<div className="journey-station-name">
|
||||
<div className="journey-name-layer journey-name-context" aria-hidden={index === focus}><FittedDestination text={stop.name} /></div>
|
||||
<div className="journey-name-layer journey-name-focus" aria-hidden={index !== focus}><FittedDestination text={stop.name} /></div>
|
||||
{(['context', 'focus'] as const).map(size => <div key={size} className={`journey-name-layer journey-name-${size}`} aria-hidden={size === 'focus' ? index !== focus : index === focus}>
|
||||
{latinName !== stop.name && <span className="journey-transliteration" lang="ru-Latn" aria-hidden="true">{latinName}</span>}
|
||||
<FittedDestination key={translit ? 'bilingual' : 'native'} text={stop.name} />
|
||||
</div>)}
|
||||
</div>
|
||||
<span className="journey-node" aria-hidden="true" />
|
||||
</li>)}
|
||||
</li>;
|
||||
})}
|
||||
{journey.more > 0 && <li className="journey-continuation"><span className="journey-continuation-label">Ещё {journey.more} остановок</span><span className="journey-continuation-line" aria-hidden="true" /></li>}
|
||||
</ol>
|
||||
</div>;
|
||||
}, (previous, next) => previous.focus === next.focus && previous.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 <><h3>Дальше по маршруту</h3>
|
||||
return <><h3>Дальше по ближайшему рейсу</h3>
|
||||
{!departure ? <p className="journey-empty">{loading ? 'Получаем расписание' : 'Нет доступного рейса'}</p> : <>
|
||||
<div className="journey-route"><RouteBadge number={departure.routeNumber} filled /><div className="journey-trip"><div className="journey-route-name"><FittedDestination text={departure.destination} /></div><time dateTime={departure.scheduledAt}><NumericText value={departure.scheduledTime} />{tomorrow && <span className="journey-day">завтра</span>}</time></div></div>
|
||||
{!journey ? <p className="journey-empty">{loading ? 'Загружаем остановки' : 'Схема маршрута пока недоступна'}</p> : !count ? <p className="journey-empty">Здесь конечная этого рейса</p> : <>
|
||||
<div className="journey-metro" data-active={active} data-calm={reading.calm} data-loop={page > 0 && focus === 0}>
|
||||
<div className="journey-tour-heading"><span>{settings.demo ? 'Демонстрация поездки' : 'После посадки'}</span><span className="journey-order">{focus + 1}-я остановка</span></div>
|
||||
<div className="journey-map-space"><JourneyRail journey={journey} focus={focus} /></div>
|
||||
<div className="journey-map-space"><JourneyRail journey={journey} focus={focus} translit={settings.journeyTranslitEnabled} /></div>
|
||||
</div>
|
||||
<div className="journey-caption"><span className="journey-caption-full">Время пути по расписанию</span><span className="journey-caption-compact">После посадки · по расписанию</span>{pages > 1 && <span className="journey-page" aria-label={`${focus + 1} из ${count} остановок в обзоре`}>{focus + 1} / {count}</span>}</div>
|
||||
</>}
|
||||
|
||||
54
src/PassengerInfo.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { RouteBadge } from './RouteBadge';
|
||||
import { FittedDestination } from './FittedDestination';
|
||||
import { NumericText } from './NumericText';
|
||||
import { Clock, Moon } from './Icons';
|
||||
import { localDate, shiftDate } from './lib/timetable.mjs';
|
||||
import { durationParts } from './lib/presentation.mjs';
|
||||
import { readability } from './lib/departures';
|
||||
import type { PlaceTrip } from './lib/passenger-guide';
|
||||
import type { FareTerms, Settings } from './types';
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
const format = new Intl.DateTimeFormat('ru', { timeZone: 'Europe/Minsk', hour: '2-digit', minute: '2-digit' });
|
||||
const checked = new Intl.DateTimeFormat('ru', { timeZone: 'Europe/Minsk', day: 'numeric', month: 'long', year: 'numeric' });
|
||||
function TripDay({ at, now }: { at: string; now: number }) {
|
||||
const day = localDate(Date.parse(at)), today = localDate(now);
|
||||
return day <= today ? null : <small>{day === shiftDate(today, 1) ? 'завтра' : day === shiftDate(today, 2) ? 'послезавтра' : day.slice(5).split('-').reverse().join('.')}</small>;
|
||||
}
|
||||
export function LastTripNotice({ number, compact = false }: { number: string; compact?: boolean }) {
|
||||
return <div className={`last-trip-notice ${compact ? 'last-trip-compact' : ''}`} role="status"><Moon size={18} /><span>Последний №{number} перед ночным перерывом <small>· по расписанию</small></span></div>;
|
||||
}
|
||||
export function PlacesCard({ trips, now, paused, active, settings, onPages }: { trips: PlaceTrip[]; now: number; paused: boolean; active: boolean; settings: Settings; onPages: (pages: number) => void }) {
|
||||
const body = useRef<HTMLDivElement>(null);
|
||||
const [height, setHeight] = useState(0), [page, setPage] = useState(0);
|
||||
const reading = readability(settings);
|
||||
const size = Math.max(1, Math.min(3, Math.floor(height / (110 * reading.scale))));
|
||||
const pages = Math.max(1, Math.ceil(trips.length / size));
|
||||
const visible = trips.slice((page % pages) * size, (page % pages + 1) * size);
|
||||
useLayoutEffect(() => {
|
||||
if (!body.current) return;
|
||||
const observer = new ResizeObserver(entries => setHeight(entries[0].contentRect.height)); observer.observe(body.current);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
useEffect(() => { if (active) onPages(pages); }, [active, pages, onPages]);
|
||||
useEffect(() => { setPage(0); }, [settings.places]);
|
||||
useEffect(() => {
|
||||
if (paused || !active || pages < 2) return;
|
||||
const timer = setInterval(() => setPage(value => value + 1), reading.calm ? 10000 : 7000);
|
||||
return () => clearInterval(timer);
|
||||
}, [paused, active, pages, reading.calm]);
|
||||
return <><h3>До важных мест</h3><div className="places-body" ref={body} style={{ '--place-rows': visible.length } as CSSProperties}>
|
||||
{visible.map((trip, index) => <article className="place-trip" key={`place-${index}`}>
|
||||
<div className="place-copy"><FittedDestination text={trip.place.name} />
|
||||
{trip.departure ? <div className="place-details"><RouteBadge number={trip.departure.routeNumber} filled /><time dateTime={trip.departure.scheduledAt}><NumericText value={trip.departure.scheduledTime} /><TripDay at={trip.departure.scheduledAt} now={now} /></time>
|
||||
{trip.minutes !== null && <span className="place-duration" aria-label={`В пути ${trip.minutes} мин по расписанию`}><Clock size={18} />{trip.minutes === 0 ? '<1 мин' : durationParts(trip.minutes).map(part => <span key={part.unit}><NumericText value={part.value} /> {part.unit}</span>)}</span>}</div>
|
||||
: <p className="place-unavailable">Ближайший прямой рейс не найден</p>}
|
||||
</div>
|
||||
{trip.arrivalAt && <div className="place-arrival"><span>Прибытие</span><time dateTime={trip.arrivalAt}><NumericText value={format.format(Date.parse(trip.arrivalAt))} /><TripDay at={trip.arrivalAt} now={now} /></time></div>}
|
||||
</article>)}
|
||||
</div><div className="passenger-caption"><span>Прямые рейсы · по расписанию</span>{pages > 1 && <span><NumericText value={`${page % pages + 1}`} /> / {pages}</span>}</div></>;
|
||||
}
|
||||
export function FareCard({ fare }: { fare: FareTerms }) {
|
||||
return <><h3>Оплата поездки</h3><div className="fare-body"><span className="fare-label">Для ближайшего маршрута</span><div className="fare-price"><FittedDestination text={fare.price} as="h2" /></div><div className="fare-payment"><FittedDestination text={fare.payment} /></div></div><div className="passenger-caption">Данные на {checked.format(Date.parse(`${fare.checkedAt}T12:00:00+03:00`))}</div></>;
|
||||
}
|
||||
40
src/PassengerSettings.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { localDate } from './lib/timetable.mjs';
|
||||
import { demoPlaces } from './lib/demo';
|
||||
import type { Settings, Stop, Route } from './types';
|
||||
|
||||
export function PassengerSettings({ settings, stops, routes, cityId, update }: { settings: Settings; stops: Stop[]; routes: Route[]; cityId: number; update: <K extends keyof Settings>(key: K, value: Settings[K]) => void }) {
|
||||
const targets = settings.demo ? demoPlaces.map(stop => ({ ...stop, direction: 'демо' })) : stops;
|
||||
return <div className="passenger-settings">
|
||||
<h4>Важные места</h4><p className="form-hint">До трёх мест. Выберите конкретную остановку назначения и короткое название для пассажиров. Табло найдёт ближайший прямой рейс с учётом его направления и конечной.</p>
|
||||
{settings.places.map((place, index) => <fieldset className="passenger-editor" key={index}><legend>Место {index + 1}</legend>
|
||||
<label>Остановка назначения<select aria-label={`Остановка места ${index + 1}`} required value={place.stopId || ''} onChange={e => { const stop = targets.find(s => s.id === Number(e.target.value)); update('places', settings.places.map((item, i) => i === index ? { stopId: Number(e.target.value), name: stop?.name.slice(0, 48) || '' } : item)); }}>
|
||||
<option value="">Выберите остановку</option>{!targets.some(stop => stop.id === place.stopId) && place.stopId > 0 && <option value={place.stopId}>{place.name} · сохранённая остановка</option>}
|
||||
{targets.filter(stop => stop.id !== settings.stopId).map(stop => <option key={stop.id} value={stop.id} disabled={settings.places.some((p, i) => i !== index && p.stopId === stop.id)}>{stop.name}{stop.direction ? ` · ${stop.direction}` : ''}</option>)}
|
||||
</select></label><label>Название на табло<input aria-label={`Название места ${index + 1}`} required maxLength={48} value={place.name} onChange={e => update('places', settings.places.map((item, i) => i === index ? { ...item, name: e.target.value } : item))} /></label>
|
||||
<button type="button" className="text-button" onClick={() => update('places', settings.places.filter((_, i) => i !== index))}>Удалить место {index + 1}</button>
|
||||
</fieldset>)}
|
||||
<button type="button" className="text-button" disabled={settings.places.length >= 3 || !targets.length} onClick={() => update('places', [...settings.places, { stopId: 0, name: '' }])}>Добавить место</button>
|
||||
<h4>Оплата поездки</h4><p className="form-hint">Основные тарифы по типу транспорта. Укажите подтверждённую цену с валютой, способы оплаты и дату проверки. Без подходящего тарифа карточка скрыта.</p>
|
||||
{(['bus', 'minibus'] as const).map(type => {
|
||||
const rule = settings.fareByType.find(rule => rule.type === type && rule.cityId === cityId);
|
||||
const label = type === 'bus' ? 'автобуса' : 'маршрутки';
|
||||
const edit = (key: 'price' | 'payment' | 'checkedAt', value: string) => update('fareByType', settings.fareByType.map(item => item.type === type && item.cityId === cityId ? { ...item, [key]: value } : item));
|
||||
return <fieldset className="passenger-editor" key={type}><legend>Тариф {label}</legend>
|
||||
<label className="switch-row"><span>Использовать тариф {label}</span><input type="checkbox" checked={!!rule} onChange={e => update('fareByType', [...settings.fareByType.filter(item => item.type !== type), ...(e.target.checked ? [{ cityId, type, price: '', payment: '', checkedAt: localDate(Date.now()) }] : [])])} /></label>
|
||||
{rule && <><div className="form-grid"><label>Стоимость с валютой<input aria-label={`Стоимость ${label}`} required maxLength={40} value={rule.price} onChange={e => edit('price', e.target.value)} /></label><label>Проверено<input aria-label={`Дата проверки ${label}`} required type="date" max={localDate(Date.now())} value={rule.checkedAt} onChange={e => edit('checkedAt', e.target.value)} /></label></div>
|
||||
<label>Способы оплаты<input aria-label={`Способы оплаты ${label}`} required maxLength={100} value={rule.payment} onChange={e => edit('payment', e.target.value)} /></label></>}
|
||||
</fieldset>;
|
||||
})}
|
||||
<h4>Исключения для маршрутов</h4><p className="form-hint">Тариф отдельного маршрута заменяет основной тариф его типа транспорта.</p>
|
||||
{settings.fareRules.map((rule, index) => <fieldset className="passenger-editor" key={index}><legend>Тариф {index + 1}</legend>
|
||||
<label>Маршрут<select aria-label={`Маршрут тарифа ${index + 1}`} required value={rule.routeId || ''} onChange={e => update('fareRules', settings.fareRules.map((item, i) => i === index ? { ...item, routeId: Number(e.target.value) } : item))}>
|
||||
<option value="">Выберите маршрут</option>{!routes.some(route => route.id === rule.routeId) && rule.routeId > 0 && <option value={rule.routeId}>Маршрут {rule.routeId} · сохранённый тариф</option>}
|
||||
{routes.map(route => <option key={route.id} value={route.id} disabled={settings.fareRules.some((r, i) => i !== index && r.routeId === route.id)}>№{route.number} · {route.name}</option>)}
|
||||
</select></label><div className="form-grid"><label>Стоимость с валютой<input aria-label={`Стоимость тарифа ${index + 1}`} required maxLength={40} placeholder="Цена с валютой" value={rule.price} onChange={e => update('fareRules', settings.fareRules.map((item, i) => i === index ? { ...item, price: e.target.value } : item))} /></label>
|
||||
<label>Проверено<input aria-label={`Дата проверки тарифа ${index + 1}`} required type="date" max={localDate(Date.now())} value={rule.checkedAt} onChange={e => update('fareRules', settings.fareRules.map((item, i) => i === index ? { ...item, checkedAt: e.target.value } : item))} /></label></div>
|
||||
<label>Способы оплаты<input aria-label={`Способы оплаты тарифа ${index + 1}`} required maxLength={100} value={rule.payment} onChange={e => update('fareRules', settings.fareRules.map((item, i) => i === index ? { ...item, payment: e.target.value } : item))} /></label>
|
||||
<button type="button" className="text-button" onClick={() => update('fareRules', settings.fareRules.filter((_, i) => i !== index))}>Удалить тариф {index + 1}</button>
|
||||
</fieldset>)}
|
||||
<button type="button" className="text-button" disabled={settings.fareRules.length >= 32 || !routes.length} onClick={() => update('fareRules', [...settings.fareRules, { routeId: 0, price: '', payment: '', checkedAt: localDate(Date.now()) }])}>Добавить тариф</button>
|
||||
</div>;
|
||||
}
|
||||
@@ -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
|
||||
<p className="form-hint">Во время ночного перерыва длиннее двух часов. Обычный экран вернётся за выбранное время до первого рейса. Работает по полному расписанию; ночью остаются первый рейс, часы и уведомления.</p>
|
||||
<h4>Блоки информации</h4>
|
||||
{informationOptions.map(block => <label className="switch-row" key={block.id}><span>{block.label}</span><input type="checkbox" checked={draft[block.setting]} onChange={event => update(block.setting, event.target.checked)} /></label>)}
|
||||
<label className="switch-row"><span>Транслит названий остановок<small>Латиницей над основным названием в схеме ближайшего рейса</small></span><input type="checkbox" disabled={!draft.infoServiceEnabled} checked={draft.journeyTranslitEnabled} onChange={event => update('journeyTranslitEnabled', event.target.checked)} /></label>
|
||||
<label className="switch-row"><span>Напоминание о последнем рейсе<small>За 30 минут до последнего рейса направления перед ночным перерывом</small></span><input type="checkbox" checked={draft.lastTripNoticeEnabled} onChange={event => update('lastTripNoticeEnabled', event.target.checked)} /></label>
|
||||
<label className="switch-row"><span>Вероятность осадков<small>Короткая подсказка в погодном блоке</small></span><input type="checkbox" checked={draft.weatherHintEnabled} onChange={event => update('weatherHintEnabled', event.target.checked)} /></label>
|
||||
<PassengerSettings settings={draft} stops={stops} routes={data?.routes || []} cityId={config.cityId} update={update} />
|
||||
{!informationBlocks(draft).length && <p className="form-hint">Экран информации появится только при предупреждениях о движении.</p>}
|
||||
</section>
|
||||
<section hidden={page !== 3} data-settings-section="3" id="settings-section-3"><h3>Рекламный экран <span className="tiny-tag">ПРОТОТИП</span></h3>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Cloud, Moon, Rain, Snow, Storm, Sun } from './Icons';
|
||||
import { NumericText } from './NumericText';
|
||||
import { weatherAvailable, weatherCondition } from './lib/weather.mjs';
|
||||
import type { Weather } from './types';
|
||||
import { precipitationHint } from './lib/weather-hint';
|
||||
|
||||
const icons = { cloud: Cloud, moon: Moon, rain: Rain, snow: Snow, storm: Storm, sun: Sun };
|
||||
const hourFormat = new Intl.DateTimeFormat('ru', { timeZone: 'Europe/Minsk', hour: '2-digit', minute: '2-digit' });
|
||||
@@ -17,12 +18,14 @@ export function WeatherSummary({ data, now, failed }: { data: Weather | null; no
|
||||
<WeatherIcon code={data.code} isDay={data.isDay} size={25} /><strong><NumericText value={degrees(data.temperature)} /></strong><span>{weatherCondition(data.code, data.isDay).label}</span>{failed && <small>сохранено</small>}
|
||||
</div>;
|
||||
}
|
||||
export function WeatherCard({ data, now, loading, failed, demo }: { data: Weather | null; now: number; loading: boolean; failed: boolean; demo: boolean }) {
|
||||
export function WeatherCard({ data, now, loading, failed, demo, hintEnabled = true }: { data: Weather | null; now: number; loading: boolean; failed: boolean; demo: boolean; hintEnabled?: boolean }) {
|
||||
const available = weatherAvailable(data, now);
|
||||
if (!available || !data) return <><h3>Погода</h3><p className="weather-empty">{demo ? 'Прогноз доступен для реальной остановки' : loading ? 'Получаем прогноз…' : 'Прогноз недоступен'}</p></>;
|
||||
const hours = data.hourly.filter(hour => Date.parse(hour.at) > now).slice(0, 3);
|
||||
const probability = hintEnabled ? precipitationHint(data, now) : null;
|
||||
return <><h3>Погода</h3><div className="weather-body" data-forecast={!!hours.length}><div className="weather-now"><div className="weather-current"><WeatherIcon code={data.code} isDay={data.isDay} size={72} /><strong><NumericText value={degrees(data.temperature)} /></strong></div>
|
||||
<p className="weather-description">{weatherCondition(data.code, data.isDay).label}</p></div>
|
||||
{!!hours.length && <div className="weather-hours" aria-label="Прогноз на следующие три часа">{hours.map(hour => <div key={hour.at}><time dateTime={hour.at}><NumericText value={hourFormat.format(Date.parse(hour.at))} /></time><WeatherIcon code={hour.code} size={48} /><strong><NumericText value={degrees(hour.temperature)} /></strong></div>)}</div>}</div>
|
||||
{probability !== null && <div className="weather-hint"><Rain size={24} /><span>Вероятность осадков в ближайший час</span><strong><NumericText value={`${probability}%`} /></strong></div>}
|
||||
<div className="weather-attribution">{failed && <span>Сохранённый прогноз</span>}<a href="https://open-meteo.com/" target="_blank" rel="noreferrer">Open-Meteo · CC BY 4.0</a></div></>;
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
68
src/lib/passenger-guide.ts
Normal file
@@ -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<Timetable['routes'][number]['stops']>; 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;
|
||||
}
|
||||
30
src/lib/passenger-settings.mjs
Normal file
@@ -0,0 +1,30 @@
|
||||
import { validDate } from './timetable.mjs';
|
||||
|
||||
const object = value => value && typeof value === 'object' && !Array.isArray(value);
|
||||
const text = (value, max) => typeof value === 'string' && value.trim().length > 0 && value.length <= max;
|
||||
const id = (value, max = 1000000) => Number.isSafeInteger(value) && value > 0 && value <= max;
|
||||
export function validPlaces(value) {
|
||||
return Array.isArray(value) && value.length <= 3 && new Set(value.map(item => item?.stopId)).size === value.length
|
||||
&& value.every(item => object(item) && Object.keys(item).every(key => ['stopId', 'name'].includes(key))
|
||||
&& id(item.stopId) && text(item.name, 48));
|
||||
}
|
||||
export function validFareRules(value) {
|
||||
return Array.isArray(value) && value.length <= 32 && new Set(value.map(item => item?.routeId)).size === value.length
|
||||
&& value.every(item => object(item) && Object.keys(item).every(key => ['routeId', 'price', 'payment', 'checkedAt'].includes(key))
|
||||
&& id(item.routeId, 999999999) && text(item.price, 40) && text(item.payment, 100) && validDate(item.checkedAt));
|
||||
}
|
||||
export function validFareByType(value) {
|
||||
return Array.isArray(value) && value.length <= 2 && new Set(value.map(item => item?.type)).size === value.length
|
||||
&& value.every(item => object(item) && Object.keys(item).every(key => ['cityId', 'type', 'price', 'payment', 'checkedAt'].includes(key))
|
||||
&& id(item.cityId) && ['bus', 'minibus'].includes(item.type) && text(item.price, 40) && text(item.payment, 100) && validDate(item.checkedAt));
|
||||
}
|
||||
// Confirmed by the operator on 2026-10-11 for Polotsk, not a provider estimate.
|
||||
// Keep that verification date fixed and bind the values to the actual city.
|
||||
export const confirmedPolotskFares = [
|
||||
{ cityId: 1, type: 'bus', price: '1,05 BYN', payment: 'QR-кодом или наличными', checkedAt: '2026-10-11' },
|
||||
{ cityId: 1, type: 'minibus', price: '2,10 BYN', payment: 'QR-кодом или наличными', checkedAt: '2026-10-11' },
|
||||
];
|
||||
export const passengerDefaults = {
|
||||
infoPlacesEnabled: true, infoFareEnabled: true, lastTripNoticeEnabled: true, weatherHintEnabled: true,
|
||||
places: [], fareRules: [], fareByType: [],
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
19
src/lib/transliteration.ts
Normal file
@@ -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<string, string> = {
|
||||
а: '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('');
|
||||
});
|
||||
}
|
||||
8
src/lib/weather-hint.ts
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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); }
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
|
||||
50
test/fixtures/passenger-server.mjs
vendored
Normal file
@@ -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(`<html lang="ru"><body style="font:20px system-ui;padding:32px"><h1>Пассажирские блоки · тестовый стенд</h1><p>Фиктивное расписание и погода. Тарифы — подтверждённые пользователем значения для Полоцка. Рабочая БД не используется.</p><form><button name="first" value="bus">Автобус ближайший</button><button name="first" value="minibus">Маршрутка ближайшая</button></form><p>Ближайший: ${firstType === 'bus' ? 'автобус' : 'маршрутка'}. Важные места здесь только для проверки.</p><button id="seed">Открыть тестовый пример</button><script>
|
||||
const seed = ${payload}; document.querySelector('#seed').onclick = async () => {
|
||||
const entries = [['ridebus-board-snapshot:1:1',seed.board],['ridebus-board-timetable:1:1',seed.t],['ridebus-board-weather:1:1',seed.weather]];
|
||||
localStorage.setItem('ridebus-board-settings-v1', JSON.stringify(seed.settings));
|
||||
for(const [key,value] of entries) localStorage.setItem(key,JSON.stringify(value));
|
||||
try { await new Promise((resolve,reject)=>{const req=indexedDB.open('ridebus-board-v1',1);req.onupgradeneeded=()=>req.result.createObjectStore('snapshots');req.onerror=reject;req.onsuccess=()=>{const db=req.result,tx=db.transaction('snapshots','readwrite');for(const [key,value] of entries)tx.objectStore('snapshots').put(value,key);tx.oncomplete=()=>{db.close();resolve();};tx.onerror=reject;};}); } catch {}
|
||||
location.href='/';
|
||||
};</script></body></html>`);
|
||||
});
|
||||
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');
|
||||
@@ -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']);
|
||||
|
||||
103
test/passenger-guide.test.mjs
Normal file
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
20
test/transliteration.test.mjs
Normal file
@@ -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);
|
||||
});
|
||||
@@ -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
|
||||
|
||||