initial commit
8
.dockerignore
Normal file
@@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
.env.*
|
||||
.git
|
||||
artifacts
|
||||
test-results
|
||||
playwright-report
|
||||
13
.env.example
Normal file
@@ -0,0 +1,13 @@
|
||||
RIDEBUS_API_BASE_URL=https://api.ridebus.by/
|
||||
RIDEBUS_API_KEY=
|
||||
BOARD_CITY_ID=1
|
||||
BOARD_CITY_NAME=Полоцк
|
||||
BOARD_STOP_NAME=Швейная фабрика
|
||||
# Optional: pin the exact platform ID. Otherwise choose it in settings.
|
||||
BOARD_STOP_ID=
|
||||
# Passenger app origin. The QR includes the selected city and stop.
|
||||
BOARD_PWA_URL=https://pwa.ridebus.by/
|
||||
# Optional commercial Open-Meteo key; selects customer-api.open-meteo.com.
|
||||
OPEN_METEO_API_KEY=
|
||||
PORT=5180
|
||||
HOST=127.0.0.1
|
||||
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
test-results/
|
||||
playwright-report/
|
||||
.DS_Store
|
||||
18
Dockerfile
Normal file
@@ -0,0 +1,18 @@
|
||||
FROM node:24-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci --no-audit --no-fund
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM node:24-alpine
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production HOST=0.0.0.0 PORT=5180
|
||||
COPY package*.json ./
|
||||
RUN npm ci --omit=dev --no-audit --no-fund
|
||||
COPY --from=build /app/dist ./dist
|
||||
COPY server ./server
|
||||
USER node
|
||||
EXPOSE 5180
|
||||
HEALTHCHECK --interval=30s --timeout=5s CMD node -e "fetch('http://127.0.0.1:5180/healthz').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"
|
||||
CMD ["node", "server/index.mjs"]
|
||||
73
README.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# RideBus Board
|
||||
|
||||
Табло остановки: React + TypeScript + Vite, сервер Node.js / Express. Фон строго `#000000`, локальный шрифт Manrope, контрастная белая типографика, настраиваемый акцент, иконки `@vkontakte/icons` и официальный SVG-логотип из RideBus PWA. По умолчанию — **Полоцк, Швейная фабрика, stopId=1**. Реальный API подтверждает эту остановку и 22 маршрута.
|
||||
|
||||
## Запуск
|
||||
|
||||
Требуется Node.js 24+ (проверено с Node 24.14.1).
|
||||
|
||||
```sh
|
||||
npm install
|
||||
cp .env.example .env
|
||||
# Добавьте RIDEBUS_API_KEY в .env.
|
||||
npm run build
|
||||
npm start
|
||||
```
|
||||
|
||||
Откройте http://127.0.0.1:5180. Для разработки — `npm run dev`. В этом workspace ключ уже подключён локально из существующей конфигурации RideBus; `.env` исключён из Git и Docker. При отсутствии ключа первая загрузка включает явно обозначенный деморежим. Демонстрацию также можно открыть по `/?demo=1`; она никогда не подменяет реальные данные при сбое API.
|
||||
|
||||
## Возможности
|
||||
|
||||
- Главный экран целиком отдан рейсам: ближайший крупно и следующие 2–6 рейсов. Строки рассчитываются по фактической доступной высоте, нижняя панель занимает отдельное место. Даже 6 следующих отправлений помещаются на 1280×720 и 390×667. На очень низких экранах список сменяется равными страницами каждые 7 секунд с явным счётчиком; рейсы не обрезаются.
|
||||
- В шапке — название и направление конкретной площадки из API, часы и дата в `Europe/Minsk`. Поле «основная» не выдаётся за направление; если направления нет, оно не придумывается. Город, подпись «Остановка», «RideBus API» и «Все направления» убраны из шапки.
|
||||
- Информационный экран: маршруты и статус полного кеша, погода, QR-код и предупреждения. Часы и ближайший рейс остаются видимыми. В вертикальной компоновке показывается одна карточка; при следующих посещениях информационного экрана карточки сменяются. Их также можно выбрать кнопками «Маршруты», «Погода», «RideBus».
|
||||
- Цикл 25 секунд / 8 секунд, настраиваемый. До ближайшего рейса ≤2 минут главный экран удерживается; можно отключить в настройках. Отмены и объезды исключают рекламную сцену из цикла.
|
||||
- Опциональная рекламная сцена с текстом и длительностью; по умолчанию выключена, помечена как предпросмотр. Загрузка медиа и кампании в MVP не входят.
|
||||
- Настройки остановки, маршрута, числа строк, интервалов и деморежима сохраняются на устройстве. Новая остановка загружает свои данные и свой кеш.
|
||||
- Полный экран, пауза, ручная смена сцен. Клавиши: `S` — настройки; `F` — полный экран; `→` — следующий экран; `пробел` — пауза. `Esc` закрывает настройки.
|
||||
- Кнопки управления и курсор скрываются после 5 секунд без взаимодействия. Движение мыши, касание или клавиатура возвращают кнопки; открытые настройки и работа с клавиатурным фокусом не скрываются. Место панели сохраняется, поэтому список рейсов не скачет.
|
||||
- Изменившиеся цифры часов, времени отправления, минут ожидания и номеров маршрутов прокручиваются вертикально. Неизменившиеся позиции остаются на месте; увеличение идёт вверх, уменьшение — вниз. При `prefers-reduced-motion` движение выключено.
|
||||
- В настройках есть 6 контрастных схем: лайм, янтарь, голубой, мята, лаванда и белый. Акценты и официальный логотип меняются вместе, фон остаётся чёрным. Все цвета имеют контраст с чёрным не меньше 7:1. Схема сохраняется на устройстве.
|
||||
- Длинные номера, включая «16тк», автоматически вписываются с отступами; текст не касается рамок.
|
||||
|
||||
## Данные и работа без связи
|
||||
|
||||
Сервер запрашивает `GET /api/v1/stops`, `/stops/:id`, `/eta/stop/:id`, `/alerts/active`, передавая `X-City-Id` и серверный `X-API-Key`. Интерфейс опрашивает сервер каждые 20 секунд; одновременные запросы объединяются, ответ кешируется на 12 секунд. Ключ, ошибки и сырые ответы upstream не отправляются в браузер. Прокси разрешает только явно заданные GET-операции: `/api/config`, `/api/stops`, `/api/board`, `/api/timetable`, `/api/weather`.
|
||||
|
||||
Текущий ETA endpoint вычисляет время из расписания и исторических задержек, а не текущей GPS-позиции. Табло показывает время с пометкой **«По расписанию»**; историческая задержка не выдаётся за прогноз. Используются абсолютные дата и время, поэтому часы не запускают истёкшие рейсы повторно.
|
||||
|
||||
Полный кеш **выбранной остановки** загружается независимо через `/api/timetable`: все варианты `/routes/:id/schedules`, остановки маршрутов с временными смещениями и `/holidays/year/:year`. Сохраняются будние, субботние, воскресные и праздничные варианты, сроки действия и ограничения отдельных рейсов. Праздник без своего графика использует воскресный, как в RideBus API. Учитываются укороченные и обратные рейсы; прибытие после полуночи сохраняет настоящую дату и обслуживающий день. Обновление каждые 30 минут, ограниченная параллельная загрузка, одновременные запросы объединяются. Кеш публикуется целиком: сбой отдельного маршрута или календаря не заменяет предыдущую полную копию неполными данными.
|
||||
|
||||
При наличии полного кеша табло рассчитывает все прибытия сегодня и завтра локально. Поэтому оно продолжает выбирать рейсы после полуночи без подключения; завтрашние времена явно помечены «завтра». Кеш хранится в IndexedDB и резервно в localStorage отдельно по городу и площадке. Срок использования без обновления ограничен **7 сутками**; по истечении этого срока табло ожидает свежую загрузку. Переводы рабочих дней могут отражаться только в календаре и расписании, которые предоставляет API. Демоданные не сохраняются вместо реального расписания. Если полный кеш ещё не получен, используется прежний снимок до трёх ближайших рейсов каждого маршрута, только для его исходного дня и не старше суток.
|
||||
|
||||
При сбое появляется пометка сохранённых данных. Истёкшие рейсы удаляются. Если сохранённые рейсы закончились, табло честно сообщает об этом и продолжает переподключение. Предупреждения фильтруются по городу, маршрутам, срокам действия; сохраняются полный текст и детали отмены. Полный кеш расписания не подтверждает актуальность предупреждений — без свежего ответа API это явно указано.
|
||||
|
||||
QR-код создаётся локально, без стороннего сервиса генерации, и открывает `https://pwa.ridebus.by/?city=1&stop=1` для текущей остановки (параметры меняются вместе с площадкой). Адрес PWA можно переопределить серверной переменной `BOARD_PWA_URL`. QR использует чёрные модули, светлый фон текущей схемы и свободное поле шириной 4 модуля.
|
||||
|
||||
Погода запрашивается независимо через `/api/weather` у [Open-Meteo](https://open-meteo.com/en/docs) по публичным координатам остановки из API. В шапке — температура и состояние; на информационном экране — ощущаемая температура, ветер в м/с и три следующих часа. Процент осадков — максимальная почасовая вероятность в этих трёх часах. Это модельные погодные данные. Обновление раз в 10 минут; сохранённая погода помечается и показывается не дольше часа, затем скрывается. При отсутствии координат или сбое погоды транспорт продолжает работать. Данные сохраняются по городу и остановке; RideBus-ключ не передаётся поставщику погоды. Указаны источник и CC BY 4.0.
|
||||
|
||||
Бесплатный Open-Meteo подходит для прототипа; [условия сервиса](https://open-meteo.com/en/terms) требуют коммерческую подписку для приложения с рекламой. Сервер уже поддерживает `OPEN_METEO_API_KEY` и автоматически выбирает коммерческий endpoint, ключ остаётся на сервере. Панель оператора планируется отдельно в `polotsk-transit-api`.
|
||||
|
||||
Production-сборка устанавливает Service Worker, сохраняющий оболочку, шрифты и иконки для перезагрузки без сети. Требуются **HTTPS или localhost** и хотя бы одна успешная загрузка; в режиме разработки Service Worker выключен. API-ответы не кешируются Service Worker. В приватном режиме браузера постоянное хранилище может быть недоступно.
|
||||
|
||||
## Проверки
|
||||
|
||||
```sh
|
||||
npm test
|
||||
npm run build
|
||||
npm run probe
|
||||
```
|
||||
|
||||
Тесты проверяют календарное время, устаревшие снимки, идентичность города/остановки, источники ETA, многомаршрутные предупреждения, сбои отдельных источников, объединение запросов и HTTP-контракт. `probe` — проверка реального API без вывода ключа.
|
||||
|
||||
## Развёртывание
|
||||
|
||||
```sh
|
||||
docker compose up --build -d
|
||||
```
|
||||
|
||||
Контейнер доступен на `127.0.0.1:5180`. Для удалённого плеера поставьте HTTPS reverse proxy перед этим портом. API-ключ передаётся только через environment; не включайте его в URL или frontend-переменные `VITE_*`. `/healthz` проверяет работу процесса, а не доступность RideBus API.
|
||||
|
||||
На Linux-плеере откройте страницу Chromium в kiosk mode и настройте автозапуск графической сессии. Пример службы — `deploy/ridebus-kiosk.service`; пути Chromium и переменные дисплея зависят от ОС. `Restart=always` восстанавливает процесс браузера. Docker восстанавливает сервер после перезагрузки. Настройку и испытание на физическом плеере нужно выполнить отдельно: служба не обнаруживает зависший renderer, а удалённый мониторинг устройств в MVP отсутствует.
|
||||
|
||||
Изменения кода выкладывайте целой сборкой; Service Worker загружает новую оболочку в фоне, активируя обновление при следующем закрытии/перезапуске табло.
|
||||
BIN
artifacts/board-fixes-fullhd.jpg
Normal file
|
After Width: | Height: | Size: 75 KiB |
BIN
artifacts/board-fixes-portrait.jpg
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
artifacts/board-fixes-tv.jpg
Normal file
|
After Width: | Height: | Size: 75 KiB |
BIN
artifacts/board-full-cache-offline.jpg
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
artifacts/board-idle-controls.jpg
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
artifacts/board-offline.jpg
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
artifacts/board-portrait.jpg
Normal file
|
After Width: | Height: | Size: 90 KiB |
BIN
artifacts/board-qr-portrait.jpg
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
artifacts/board-tv.jpg
Normal file
|
After Width: | Height: | Size: 55 KiB |
BIN
artifacts/board-weather-portrait.jpg
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
artifacts/board-weather-qr-tv.jpg
Normal file
|
After Width: | Height: | Size: 94 KiB |
12
artifacts/verification-cache-weather-qr.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# Полный кеш, QR и погода — 8 октября 2026
|
||||
|
||||
- Финальная production-сборка и TypeScript прошли. `npm test`: 41/41 тест. HTTP-тесты запускались с разрешением открыть временный loopback-порт.
|
||||
- Реальный RideBus API для cityId=1, stopId=1: 22 маршрута, 82 варианта расписания, 2026 записей рейсов во всех вариантах; 548 прибытий на 8 октября. Нормализованный пакет около 215 КБ. Это график выбранной остановки, а не кеш всего города.
|
||||
- Тесты покрывают будни/выходные, праздники и воскресный fallback, сроки действия, ограничения рейсов, обратные/укороченные рейсы, прибытие после полуночи и срок использования кеша 7 суток. Переход через полуночь проверен фиксированными датами в тестах, без изменения системных часов.
|
||||
- Реальный Open-Meteo вернул погодные данные по координатам остановки и 48 почасовых значений. Проверены единицы м/с, Unix timestamp, отсутствие RideBus-ключа в запросах к погоде, обработка null, срок 1 час и повтор после сбоя.
|
||||
- Production-сервер остановлен, вкладка перезагружена. Оболочка, полный кеш, завтрашний рейс №22 в 00:02 и следующие утренние рейсы восстановились. Погода помечена «сохранено», актуальность предупреждений не подтверждается. Затем сервер восстановлен. Снимок: board-full-cache-offline.jpg (для проверки выбран маршрут №22).
|
||||
- 1280×720: все три информационные карточки имеют clientHeight == scrollHeight (348 px), включая случай ближайшего рейса завтра. Шесть последующих строк: последняя заканчивается на y=649.48, нижняя панель начинается на y=661.
|
||||
- 390×667: погодная карточка clientHeight == scrollHeight (296 px), QR-карточка помещается. Снимки board-weather-portrait.jpg и board-qr-portrait.jpg. Портретные снимки сделаны с фильтром №22, чтобы проверить карточки отдельно от поступивших реальных предупреждений.
|
||||
- QR-ссылка открыта в публичной PWA: https://pwa.ridebus.by/?city=1&stop=1. В городе выбран Полоцк, открыта Швейная фабрика. Проверены параметры ссылки, локальная генерация SVG и тихая зона 4 модуля. Сканирование физической камерой не выполнялось.
|
||||
- Итоговый board-weather-qr-tv.jpg показывает реальные данные и все маршруты остановки. После проверки восстановлены все маршруты, 4 следующие строки, приоритет близкого рейса, автоматический цикл; временный viewport сброшен.
|
||||
- Изменены backend API и модули расписания/погоды, клиентские hooks/хранилище, информационные карточки, шапка и CSS. Обновлены README, .env.example, compose.yaml; добавлены тесты timetable/weather и проверки HTTP/хранилища. Панель оператора не входит в эти изменения.
|
||||
10
artifacts/verification-idle-controls.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# Автоскрытие управления — 8 октября 2026
|
||||
|
||||
- TypeScript и production-сборка прошли без ошибок.
|
||||
- После 5 секунд без взаимодействия `.board-controls` имеет opacity=0; курсор скрыт.
|
||||
- Положение нижней панели и последней строки осталось прежним: y=661 и y≈649 при 1280×720.
|
||||
- Клавиатура и реальное действие мышью возвращают панель.
|
||||
- При клавиатурном фокусе на кнопке панель остаётся видимой после 5,5 секунд.
|
||||
- Открытые настройки остаются видимыми, таймер не скрывает управление.
|
||||
- После закрытия настроек и завершения взаимодействия автоскрытие возобновляется.
|
||||
- Снимок: `board-idle-controls.jpg`.
|
||||
17
artifacts/verification-ui-fixes.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# Исправления табло — 8 октября 2026
|
||||
|
||||
- `npm run build`: TypeScript и production-сборка прошли без ошибок и предупреждений.
|
||||
- `npm test`: 28 тестов, 28 passed. Дополнительно проверены контраст всех 6 палитр (не ниже 7:1 с чёрным), перенос разрядов при анимации чисел, пагинация коротких экранов и направление площадки.
|
||||
- Главный экран с шестью следующими рейсами: 1920×1080, 1280×720, 768×1024, 512×768 и 390×667. Ширина и высота документа совпадают с viewport; последняя строка выше нижней панели. Пять строк отдельно проверены при 1280×720.
|
||||
- Full HD, 6 строк: последняя строка заканчивается на y≈1004, панель начинается на y=1021. При 390×667: последняя строка на y≈603, панель на y=614.
|
||||
- «16тк» проверен в большой и обычной рамке. Отступы слева/справа положительны; большой номер в Full HD имеет отступ около 27 px.
|
||||
- Длинное реальное направление «Больница (Новка) (через Аэропорт)» проверено на узком экране: заголовок полностью находится в своей строке.
|
||||
- Подключён `@vkontakte/icons`, Lucide удалён. Геометрия официального SVG из `ridebus-pwa/public/logo.svg` сохранена; цвета переведены на акцент схемы и чёрный.
|
||||
- Схема «Голубой» выбрана через настройки и сохранена после reload. Акцент, рамка главного рейса и заливка логотипа: rgb(121,231,255); фон rgb(0,0,0).
|
||||
- Реальная площадка «Экимань · на Заполотье»: шапка показывает «Экимань» / «На Заполотье». Для площадки «основная» направление не придумывается.
|
||||
- Прокрутка цифр часов подтверждена в браузере: изменившиеся позиции имеют анимацию `digit-up-in`. Уменьшение и переходы разрядов проверены тестами; учтён reduced motion.
|
||||
- При 390×667 все 22 маршрута «Швейной фабрики» помещаются в отдельной информационной карточке (scrollHeight = clientHeight = 316). Карточки «О времени» и «RideBus» переключены через UI.
|
||||
- В конце проверки восстановлены Швейная фабрика, лайм, 4 следующих рейса, обычный цикл и удержание близкого рейса. Деморежим, реклама и пауза выключены.
|
||||
- Скриншоты `board-fixes-tv.jpg` (1280×720) и `board-fixes-portrait.jpg` (512×768): реальные рейсы Швейной фабрики, 6 следующих отправлений. Направление площадки отдельно проверено на Экимани.
|
||||
|
||||
Проверка выполнялась в браузере. Физический уличный дисплей и читаемость с конкретного расстояния не проверялись.
|
||||
12
artifacts/verification.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# Проверка MVP — 8 октября 2026
|
||||
|
||||
- `npm test`: 24 теста, 24 passed, 0 failed. HTTP-контракт, время и устаревшие снимки, предупреждения, кеширование запросов, резервное хранилище.
|
||||
- `npm run build`: TypeScript и production-сборка прошли без ошибок и предупреждений.
|
||||
- Live RideBus API: Полоцк, stopId=1 «Швейная фабрика», 22 маршрута. В интерфейсе видны реальные рейсы; ключ отсутствует в frontend JS/CSS.
|
||||
- Браузер: сохранение настроек, 6 строк, ручная и автоматическая смена сцен, рекламный предпросмотр, часы и ближайший рейс на второстепенных сценах.
|
||||
- Макеты 1920×1080, 1280×720 и 1080×1920: документ помещается в высоту и ширину. При ширине 390 горизонтальная прокрутка отсутствует; интерфейс использует вертикальную компоновку.
|
||||
- Offline: локальный сервер остановлен, страница перезагружена; оболочка загружена Service Worker, реальный снимок восстановлен с меткой «Сохранённые данные». После запуска сервера данные снова обновляются.
|
||||
- Заключительная проверка браузера: ошибок/предупреждений нет. Реклама отключена, цикл 25/8 сек, 4 следующих рейса, удержание ближайшего рейса включено, пауза выключена.
|
||||
- Снимки: `board-tv.jpg` — основной экран; `board-offline.jpg` — сохранённые данные при остановленном сервере; `board-portrait.jpg` — вертикальная компоновка.
|
||||
|
||||
Физический ТВ/уличный плеер, Docker-сборка и служба автозапуска не испытывались. В MVP загружаются до трёх ближайших рейсов каждого маршрута из ETA API, а не полный суточный график. GPS-прогноз не заявляется.
|
||||
16
compose.yaml
Normal file
@@ -0,0 +1,16 @@
|
||||
services:
|
||||
board:
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
init: true
|
||||
environment:
|
||||
RIDEBUS_API_BASE_URL: ${RIDEBUS_API_BASE_URL:-https://api.ridebus.by/}
|
||||
RIDEBUS_API_KEY: ${RIDEBUS_API_KEY:-}
|
||||
BOARD_CITY_ID: ${BOARD_CITY_ID:-1}
|
||||
BOARD_CITY_NAME: ${BOARD_CITY_NAME:-Полоцк}
|
||||
BOARD_STOP_NAME: ${BOARD_STOP_NAME:-Швейная фабрика}
|
||||
BOARD_STOP_ID: ${BOARD_STOP_ID:-1}
|
||||
BOARD_PWA_URL: ${BOARD_PWA_URL:-https://pwa.ridebus.by/}
|
||||
OPEN_METEO_API_KEY: ${OPEN_METEO_API_KEY:-}
|
||||
ports:
|
||||
- "127.0.0.1:${BOARD_PORT:-5180}:5180"
|
||||
16
deploy/ridebus-kiosk.service
Normal file
@@ -0,0 +1,16 @@
|
||||
# Install in ~/.config/systemd/user/ridebus-kiosk.service on the Linux player.
|
||||
# The service needs a running graphical session. Adapt the chromium binary and
|
||||
# DISPLAY/WAYLAND_DISPLAY to your distribution; see README.
|
||||
[Unit]
|
||||
Description=RideBus Board kiosk
|
||||
After=graphical-session.target
|
||||
PartOf=graphical-session.target
|
||||
|
||||
[Service]
|
||||
Environment=BOARD_URL=http://127.0.0.1:5180
|
||||
ExecStart=/usr/bin/chromium --kiosk --no-first-run --disable-session-crashed-bubble ${BOARD_URL}
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=graphical-session.target
|
||||
16
index.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta name="description" content="RideBus — ближайший транспорт на вашей остановке." />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<title>Швейная фабрика · RideBus Board</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
2558
package-lock.json
generated
Normal file
33
package.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "ridebus-board",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=24.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "node --env-file-if-exists=.env server/index.mjs --dev",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"start": "node --env-file-if-exists=.env server/index.mjs",
|
||||
"test": "node --test test/*.test.mjs",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"probe": "node --env-file-if-exists=.env scripts/probe-api.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/manrope": "^5.3.0",
|
||||
"@vkontakte/icons": "^3.75.0",
|
||||
"express": "^5.1.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.3.0",
|
||||
"react-dom": "^19.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"@vitejs/plugin-react": "^6.1.2",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^8.3.3"
|
||||
}
|
||||
}
|
||||
37
public/favicon.svg
Normal file
@@ -0,0 +1,37 @@
|
||||
|
||||
|
||||
<svg version="1.1" id="Слой_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 59 58" style="enable-background:new 0 0 59 58;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{clip-path:url(#SVGID_2_);}
|
||||
.st1{fill:#d5ff70;}
|
||||
.st2{display:none;fill:none;stroke:#FFFFFF;stroke-width:1.9625;}
|
||||
.st3{fill:#000000;}
|
||||
</style>
|
||||
<g>
|
||||
<defs>
|
||||
<rect id="SVGID_1_" x="0.5" width="58" height="58"/>
|
||||
</defs>
|
||||
<clipPath id="SVGID_2_">
|
||||
<use xlink:href="#SVGID_1_" style="overflow:visible;"/>
|
||||
</clipPath>
|
||||
<g class="st0">
|
||||
<path class="st1" d="M21.9,1.3l19.7,1.6C51,3.6,58,11.8,57.2,21.2l-1.6,19.7c-0.7,9.4-8.9,16.3-18.3,15.6l-19.7-1.6
|
||||
C8.4,54.2,1.4,46,2.1,36.6l1.6-19.7C4.4,7.6,12.6,0.6,21.9,1.3z"/>
|
||||
<path class="st2" d="M23.6,38.6C0.3,23.3,37,15.1,40.3,16 M23.6,38.6C46.1,53.4,41.7,19.3,40.3,16 M23.6,38.6
|
||||
c10.2,3,15.1-17,16.7-22.6"/>
|
||||
</g>
|
||||
</g>
|
||||
<path id="path" class="st3" d="M20.3,44.4c-1.6,0-2.2-0.5-2.3-1.7c0-0.8,0-1.6,0-2.4c0-0.2-0.1-0.3-0.2-0.5c-1-1.2-1.6-2.7-1.5-4.2
|
||||
c0.1-5.1,0-10.1,0-15.2c0-0.8,0.1-1.6,0.3-2.3c0.6-1.9,2-3,3.8-3.5c2.1-0.7,4.3-1,6.5-0.9c2.7,0,5.5,0,8.2,0.2
|
||||
c2.1,0.2,4.2,0.7,5.8,2.3c0.3,0.2,0.5,0.5,0.6,0.8c0.8,1.3,1.2,3,1,4.5c0,2.3,0,4.6-0.1,6.8l0,0c0,2.6,0,5.1,0,7.8
|
||||
c0,1.3-0.5,2.7-1.4,3.7c-0.3,0.3-0.3,0.6-0.3,0.9c0,0.8,0,1.5,0,2.1c0.1,0.6-0.3,1.2-0.8,1.5c-0.2,0.2-0.5,0.3-0.8,0.3
|
||||
c-0.6,0-1.2,0-1.9,0c-0.9,0-1.6-0.7-1.7-1.6c0-0.4,0-0.8,0-1.2c0-0.2-0.2-0.3-0.3-0.4l0,0H23.3c-0.3,0-0.4,0.1-0.3,0.4
|
||||
c0,0.3,0,0.8,0,1.1c0,0.9-0.8,1.7-1.6,1.7 M29.4,20.2L29.4,20.2c-3.1,0-6.2,0-9.2,0c-0.3,0-0.5,0.1-0.5,0.5c0,2.3,0,4.6,0,7
|
||||
c0,0.3,0.1,0.5,0.5,0.5c6.2,0,12.2,0,18.4,0c0.4,0,0.5-0.1,0.5-0.5c0-2.3,0-4.6,0-6.8c0-0.3-0.3-0.6-0.6-0.6l0,0
|
||||
C35.5,20.2,32.5,20.2,29.4,20.2z M37,37.9c1.3-0.2,2.3-1.3,2.1-2.6c-0.2-1.3-1.3-2.3-2.6-2.1c-0.2,0-0.3,0.1-0.5,0.1
|
||||
c-1.3,0.4-1.9,1.8-1.5,3C34.9,37.3,35.9,38,37,37.9L37,37.9z M21.7,37.9c1.3,0.2,2.5-0.6,2.7-1.9c0.2-1.3-0.6-2.5-1.9-2.7
|
||||
c-1.2-0.2-2.5,0.6-2.7,1.9c-0.1,0.2-0.1,0.3-0.1,0.5C19.8,36.8,20.7,37.7,21.7,37.9L21.7,37.9z M26.6,36.2c-0.1,1,0.5,2,1.4,2.4
|
||||
c0.9,0.5,2.1,0.3,3-0.2c0.8-0.5,1.2-1.3,1.1-2.2h-1.2c-0.3,1-0.8,1.5-1.6,1.5c-0.8,0-1.3-0.4-1.6-1.5C27.8,36.2,26.6,36.2,26.6,36.2
|
||||
z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
1
public/manifest.webmanifest
Normal file
@@ -0,0 +1 @@
|
||||
{"name":"RideBus Board","short_name":"RideBus","start_url":"/","display":"fullscreen","background_color":"#000000","theme_color":"#000000","lang":"ru","icons":[{"src":"/favicon.svg","sizes":"any","type":"image/svg+xml","purpose":"any"}]}
|
||||
24
scripts/probe-api.mjs
Normal file
@@ -0,0 +1,24 @@
|
||||
const key = process.env.RIDEBUS_API_KEY;
|
||||
if (!key) { console.log('RideBus API key is not configured.'); process.exit(1); }
|
||||
const base = (process.env.RIDEBUS_API_BASE_URL || 'https://api.ridebus.by/').replace(/\/+$/, '') + '/';
|
||||
const cityId = process.env.BOARD_CITY_ID || '1';
|
||||
async function get(path) {
|
||||
const response = await fetch(new URL(path, base), {
|
||||
headers: { 'X-API-Key': key, 'X-City-Id': cityId, Accept: 'application/json' },
|
||||
signal: AbortSignal.timeout(12000), redirect: 'error',
|
||||
});
|
||||
if (!response.ok) throw new Error(`API returned HTTP ${response.status}`);
|
||||
return response.json();
|
||||
}
|
||||
try {
|
||||
const stops = await get('api/v1/stops');
|
||||
const matches = stops.filter(stop => /швейн/i.test(stop.name));
|
||||
console.log(JSON.stringify(matches.map(({ id, name, description, routes_count }) => ({ id, name, description, routes_count })), null, 2));
|
||||
for (const stop of matches) {
|
||||
const [details, eta] = await Promise.all([get(`api/v1/stops/${stop.id}`), get(`api/v1/eta/stop/${stop.id}`)]);
|
||||
console.log(JSON.stringify({ stopId: stop.id, routes: details.routes.map(({ id, route_number, name }) => ({ id, route_number, name })), upcomingCount: eta.routes.reduce((count, route) => count + (route.arrivals?.length || 0), 0) }, null, 2));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error.cause?.code || error.message);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
31
scripts/shell-cache.mjs
Normal file
@@ -0,0 +1,31 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
export function shellCachePlugin() {
|
||||
return {
|
||||
name: 'ridebus-offline-shell',
|
||||
generateBundle(_options, bundle) {
|
||||
const files = ['/', '/favicon.svg', '/manifest.webmanifest', ...Object.keys(bundle).map(file => '/' + file)];
|
||||
const version = createHash('sha256').update(Object.keys(bundle).join('|')).digest('hex').slice(0, 12);
|
||||
this.emitFile({ type: 'asset', fileName: 'sw.js', source: `
|
||||
const CACHE = 'ridebus-board-${version}';
|
||||
const FILES = ${JSON.stringify(files)};
|
||||
self.addEventListener('install', event => {
|
||||
event.waitUntil(caches.open(CACHE).then(cache => cache.addAll(FILES)));
|
||||
});
|
||||
self.addEventListener('activate', event => {
|
||||
event.waitUntil(caches.keys().then(keys => Promise.all(keys.filter(key => key.startsWith('ridebus-board-') && key !== CACHE).map(key => caches.delete(key)))).then(() => self.clients.claim()));
|
||||
});
|
||||
self.addEventListener('message', event => { if (event.data === 'ACTIVATE') self.skipWaiting(); });
|
||||
self.addEventListener('fetch', event => {
|
||||
const url = new URL(event.request.url);
|
||||
if (event.request.method !== 'GET' || url.origin !== self.location.origin || url.pathname.startsWith('/api/') || url.pathname === '/healthz') return;
|
||||
if (event.request.mode === 'navigate') {
|
||||
event.respondWith(fetch(event.request).catch(() => caches.open(CACHE).then(cache => cache.match('/'))));
|
||||
} else if (FILES.includes(url.pathname)) {
|
||||
event.respondWith(caches.open(CACHE).then(async cache => (await cache.match(url.pathname)) || fetch(event.request)));
|
||||
}
|
||||
});
|
||||
` });
|
||||
},
|
||||
};
|
||||
}
|
||||
149
server/board.mjs
Normal file
@@ -0,0 +1,149 @@
|
||||
import { loadTimetable } from './timetable.mjs';
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(status = 502, code = 'data_unavailable') { super(code); this.status = status; this.code = code; }
|
||||
}
|
||||
|
||||
const clean = value => typeof value === 'string' ? value.trim() : '';
|
||||
const ids = value => Array.isArray(value) ? value.map(Number).filter(id => Number.isSafeInteger(id) && id > 0) : [];
|
||||
const strings = value => Array.isArray(value) ? value.map(clean).filter(Boolean) : [];
|
||||
export const localDate = now => new Intl.DateTimeFormat('sv-SE', { timeZone: 'Europe/Minsk', year: 'numeric', month: '2-digit', day: '2-digit' }).format(now);
|
||||
|
||||
export function scheduledInstant(time, now) {
|
||||
const match = /^(\d{1,2}):([0-5]\d)(?::([0-5]\d))?$/.exec(clean(time));
|
||||
if (!match || Number(match[1]) > 23) return null;
|
||||
// ETA is a same-day timetable in Minsk. Never move an expired trip to tomorrow.
|
||||
return `${localDate(now)}T${match[1].padStart(2, '0')}:${match[2]}:${match[3] || '00'}+03:00`;
|
||||
}
|
||||
|
||||
function routeInfo(route) {
|
||||
return { id: Number(route.id), number: clean(route.route_number), name: clean(route.name), type: route.type === 'minibus' ? 'minibus' : 'bus' };
|
||||
}
|
||||
|
||||
export function normalizeDepartures(raw, now, stopNames = new Map()) {
|
||||
if (!raw || !Array.isArray(raw.routes)) throw new ApiError();
|
||||
let partial = false;
|
||||
const departures = [];
|
||||
const routes = [];
|
||||
for (const item of raw.routes) {
|
||||
if (!item || !Number.isSafeInteger(Number(item.id)) || !clean(item.route_number) || !clean(item.name)) { partial = true; continue; }
|
||||
const route = routeInfo(item);
|
||||
routes.push(route);
|
||||
if (item.error || !Array.isArray(item.arrivals)) { partial = true; continue; }
|
||||
for (const arrival of item.arrivals) {
|
||||
const scheduledAt = scheduledInstant(arrival?.scheduledTime, now);
|
||||
if (!scheduledAt) { partial = true; continue; }
|
||||
if (Date.parse(scheduledAt) + 60000 <= now) continue;
|
||||
const note = clean(arrival.trip?.note);
|
||||
const terminal = stopNames.get(Number(arrival.trip?.endStopId));
|
||||
const destination = terminal || route.name.split(/\s+[-–—]\s+/).at(-1) || route.name;
|
||||
const identity = [route.id, scheduledAt, arrival.trip?.departureTime || '', arrival.trip?.startStopId || '', arrival.trip?.endStopId || '', note].join(':');
|
||||
departures.push({ id: identity, routeId: route.id, routeNumber: route.number, routeName: route.name,
|
||||
destination, type: route.type, scheduledAt, scheduledTime: clean(arrival.scheduledTime).slice(0, 5), note,
|
||||
source: 'schedule' });
|
||||
}
|
||||
}
|
||||
return { departures: [...new Map(departures.map(item => [item.id, item])).values()].sort((a, b) => Date.parse(a.scheduledAt) - Date.parse(b.scheduledAt)),
|
||||
routes: routes.sort((a, b) => a.number.localeCompare(b.number, 'ru', { numeric: true })), partial };
|
||||
}
|
||||
|
||||
export function normalizeAlerts(raw, routes, cityId, now) {
|
||||
if (!Array.isArray(raw)) throw new ApiError();
|
||||
const routeIds = new Set(routes.map(route => route.id));
|
||||
const routeNumbers = new Set(routes.map(route => route.number));
|
||||
return [...new Map(raw.filter(alert => {
|
||||
if (!alert || alert.is_active === false || alert.city_id != null && Number(alert.city_id) !== cityId) return false;
|
||||
const start = alert.start_time == null ? null : Date.parse(alert.start_time);
|
||||
const end = alert.end_time == null ? null : Date.parse(alert.end_time);
|
||||
if (start !== null && (!Number.isFinite(start) || start > now) || end !== null && (!Number.isFinite(end) || end <= now)) return false;
|
||||
const targets = ids(alert.route_ids);
|
||||
if (!targets.length && Number(alert.route_id) > 0) targets.push(Number(alert.route_id));
|
||||
const numbers = strings(alert.route_numbers);
|
||||
if (!numbers.length && clean(alert.route_number)) numbers.push(clean(alert.route_number));
|
||||
return targets.length === 0 && numbers.length === 0 || targets.some(id => routeIds.has(id)) || numbers.some(number => routeNumbers.has(number));
|
||||
}).map(alert => {
|
||||
const numbers = strings(alert.route_numbers);
|
||||
if (!numbers.length && clean(alert.route_number)) numbers.push(clean(alert.route_number));
|
||||
const targets = ids(alert.route_ids);
|
||||
if (!targets.length && Number(alert.route_id) > 0) targets.push(Number(alert.route_id));
|
||||
const value = { id: String(alert.id), title: clean(alert.title) || 'Изменения движения', message: clean(alert.message),
|
||||
type: ['cancellation', 'delay', 'detour'].includes(alert.alert_type) ? alert.alert_type : 'info',
|
||||
routeNumbers: [...new Set(numbers)], routeIds: [...new Set(targets)],
|
||||
startAt: alert.start_time || null, endAt: alert.end_time || null };
|
||||
return [value.id, value];
|
||||
})).values()];
|
||||
}
|
||||
|
||||
export function createBoardClient({
|
||||
baseUrl = process.env.RIDEBUS_API_BASE_URL || 'https://api.ridebus.by/', apiKey = process.env.RIDEBUS_API_KEY,
|
||||
cityId = Number(process.env.BOARD_CITY_ID || 1), cityName = process.env.BOARD_CITY_NAME || 'Полоцк',
|
||||
stopName = process.env.BOARD_STOP_NAME || 'Швейная фабрика', defaultStopId = Number(process.env.BOARD_STOP_ID || 1),
|
||||
passengerBaseUrl = process.env.BOARD_PWA_URL || 'https://pwa.ridebus.by/',
|
||||
fetchData = fetch, now = Date.now,
|
||||
} = {}) {
|
||||
const cache = new Map();
|
||||
const pending = new Map();
|
||||
const url = new URL(baseUrl.endsWith('/') ? baseUrl : baseUrl + '/');
|
||||
if (!['https:', 'http:'].includes(url.protocol) || !Number.isSafeInteger(cityId) || cityId < 1) throw new Error('Invalid RideBus configuration');
|
||||
async function upstream(path) {
|
||||
if (!apiKey?.trim()) throw new ApiError(503, 'api_not_configured');
|
||||
try {
|
||||
const response = await fetchData(new URL(path, url), { headers: { Accept: 'application/json', 'X-API-Key': apiKey, 'X-City-Id': String(cityId) },
|
||||
redirect: 'error', signal: AbortSignal.timeout(10000) });
|
||||
if (!response.ok) throw new ApiError(response.status === 404 ? 404 : 502);
|
||||
return await response.json();
|
||||
} catch (error) { throw error instanceof ApiError ? error : new ApiError(); }
|
||||
}
|
||||
function cached(key, ttl, load) {
|
||||
const entry = cache.get(key);
|
||||
if (entry?.expires > now()) return Promise.resolve(entry.data);
|
||||
if (pending.has(key)) return pending.get(key);
|
||||
const request = load().then(data => { cache.set(key, { expires: now() + ttl, data }); return data; }).finally(() => pending.delete(key));
|
||||
pending.set(key, request);
|
||||
return request;
|
||||
}
|
||||
function catalog() {
|
||||
return cached('catalog', 300000, async () => {
|
||||
const raw = await upstream('api/v1/stops');
|
||||
if (!Array.isArray(raw)) throw new ApiError();
|
||||
return raw.filter(stop => Number(stop.id) > 0 && clean(stop.name));
|
||||
});
|
||||
}
|
||||
async function stopDetails(stopId) {
|
||||
if (!Number.isSafeInteger(stopId) || stopId < 1) throw new ApiError(400, 'invalid_stop');
|
||||
if (!(await catalog()).some(stop => Number(stop.id) === stopId)) throw new ApiError(404, 'stop_not_found');
|
||||
const stop = await cached(`stop:${stopId}`, 300000, () => upstream(`api/v1/stops/${stopId}`));
|
||||
if (Number(stop.id) !== stopId || stop.city_id != null && Number(stop.city_id) !== cityId) throw new ApiError();
|
||||
return stop;
|
||||
}
|
||||
const config = () => ({ cityId, cityName, stopName, defaultStopId, configured: Boolean(apiKey?.trim()), timeZone: 'Europe/Minsk', refreshSeconds: 20, passengerBaseUrl });
|
||||
return {
|
||||
config,
|
||||
async coordinates(stopId) { const stop = await stopDetails(stopId); return { latitude: stop.latitude, longitude: stop.longitude }; },
|
||||
async timetable(stopId) {
|
||||
const stop = await stopDetails(stopId);
|
||||
return cached(`timetable:${stopId}`, 1800000, () => loadTimetable({ upstream, cityId, cityName, stop, now: now() }));
|
||||
},
|
||||
async stops() { return (await catalog()).map(stop => ({ id: Number(stop.id), name: clean(stop.name), direction: clean(stop.description) })); },
|
||||
async board(stopId) {
|
||||
if (!Number.isSafeInteger(stopId) || stopId < 1) throw new ApiError(400, 'invalid_stop');
|
||||
const stops = await catalog();
|
||||
const catalogStop = stops.find(stop => Number(stop.id) === stopId);
|
||||
if (!catalogStop) throw new ApiError(404, 'stop_not_found');
|
||||
return cached(`board:${stopId}`, 12000, async () => {
|
||||
const [stop, eta] = await Promise.all([
|
||||
cached(`stop:${stopId}`, 300000, () => upstream(`api/v1/stops/${stopId}`)), upstream(`api/v1/eta/stop/${stopId}`),
|
||||
]);
|
||||
if (Number(stop.id) !== stopId || stop.city_id != null && Number(stop.city_id) !== cityId) throw new ApiError();
|
||||
const generatedAt = now();
|
||||
const data = normalizeDepartures(eta, generatedAt, new Map(stops.map(stop => [Number(stop.id), clean(stop.name)])));
|
||||
const stopRoutes = Array.isArray(stop.routes) ? stop.routes.filter(route => route?.is_active !== false).map(routeInfo) : data.routes;
|
||||
let alerts = [], alertsAvailable = true;
|
||||
try { alerts = normalizeAlerts(await cached('alerts', 20000, () => upstream('api/v1/alerts/active')), stopRoutes, cityId, now()); }
|
||||
catch { alertsAvailable = false; }
|
||||
return { cityId, cityName, stop: { id: stopId, name: clean(stop.name), direction: clean(stop.description) },
|
||||
...data, alerts, alertsAvailable, updatedAt: new Date(generatedAt).toISOString(), serviceDate: localDate(generatedAt), mode: 'live' };
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
59
server/index.mjs
Normal file
@@ -0,0 +1,59 @@
|
||||
import express from 'express';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { resolve } from 'node:path';
|
||||
import { ApiError, createBoardClient } from './board.mjs';
|
||||
import { createWeatherClient } from './weather.mjs';
|
||||
|
||||
export function createApp(client = createBoardClient(), weatherClient = createWeatherClient()) {
|
||||
const app = express();
|
||||
app.disable('x-powered-by');
|
||||
app.use((_req, res, next) => { res.setHeader('X-Content-Type-Options', 'nosniff'); next(); });
|
||||
app.get('/healthz', (_req, res) => res.json({ status: 'ok' }));
|
||||
app.use('/api', (req, res, next) => {
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
if (req.method !== 'GET') { res.setHeader('Allow', 'GET'); res.status(405).json({ error: 'method_not_allowed' }); return; }
|
||||
next();
|
||||
});
|
||||
app.get('/api/config', (_req, res) => res.json(client.config()));
|
||||
app.get('/api/stops', async (_req, res) => { res.json(await client.stops()); });
|
||||
app.get('/api/board', async (req, res) => {
|
||||
const raw = req.query.stopId;
|
||||
if (typeof raw !== 'string' || !/^[1-9]\d*$/.test(raw)) throw new ApiError(400, 'invalid_stop');
|
||||
const data = await client.board(Number(raw));
|
||||
res.json({ ...data, serverTime: new Date().toISOString() });
|
||||
});
|
||||
const stopId = req => {
|
||||
if (typeof req.query.stopId !== 'string' || !/^[1-9]\d*$/.test(req.query.stopId) || !Number.isSafeInteger(Number(req.query.stopId))) throw new ApiError(400, 'invalid_stop');
|
||||
return Number(req.query.stopId);
|
||||
};
|
||||
app.get('/api/timetable', async (req, res) => res.json(await client.timetable(stopId(req))));
|
||||
app.get('/api/weather', async (req, res) => {
|
||||
const id = stopId(req);
|
||||
res.json(await weatherClient.weather(client.config().cityId, id, await client.coordinates(id)));
|
||||
});
|
||||
app.use('/api', (_req, res) => res.status(404).json({ error: 'not_found' }));
|
||||
// Never return upstream bodies, stack traces or API credentials.
|
||||
app.use((error, _req, res, _next) => { res.status(error instanceof ApiError ? error.status : 502).json({ error: error instanceof ApiError ? error.code : 'data_unavailable' }); });
|
||||
return app;
|
||||
}
|
||||
|
||||
async function start() {
|
||||
const root = fileURLToPath(new URL('..', import.meta.url));
|
||||
const dev = process.argv.includes('--dev');
|
||||
const app = createApp();
|
||||
if (dev) {
|
||||
const vite = await (await import('vite')).createServer({ root, server: { middlewareMode: true }, appType: 'spa' });
|
||||
app.use(vite.middlewares);
|
||||
} else {
|
||||
const dist = resolve(root, 'dist');
|
||||
if (!existsSync(resolve(dist, 'index.html'))) throw new Error('Run npm run build first.');
|
||||
app.use(express.static(dist, { setHeaders: (res, file) => res.setHeader('Cache-Control', file.includes('/assets/') ? 'public, max-age=31536000, immutable' : 'no-cache') }));
|
||||
app.get('/{*path}', (_req, res) => res.sendFile(resolve(dist, 'index.html')));
|
||||
}
|
||||
const host = process.env.HOST || '127.0.0.1';
|
||||
const port = Number(process.env.PORT || 5180);
|
||||
const server = app.listen(port, host, () => console.log(`RideBus Board: http://${host}:${port} (${dev ? 'development' : 'production'})`));
|
||||
server.on('error', error => { console.error(error.code === 'EADDRINUSE' ? `Port ${port} is in use.` : 'Unable to start server.'); process.exitCode = 1; });
|
||||
}
|
||||
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) start().catch(error => { console.error(error.message); process.exitCode = 1; });
|
||||
73
server/timetable.mjs
Normal file
@@ -0,0 +1,73 @@
|
||||
import { ApiError } from './board.mjs';
|
||||
import { TIMETABLE_MAX_MS, localDate, shiftDate, validDate } from '../src/lib/timetable.mjs';
|
||||
|
||||
function normalizeSchedules(raw, route, rawStops, stopId) {
|
||||
if (!Array.isArray(raw) || !Array.isArray(rawStops)) throw new ApiError();
|
||||
const stops = new Map(rawStops.map(stop => [Number(stop.stop_id), stop]));
|
||||
const target = stops.get(stopId);
|
||||
if (!target || !Number.isFinite(Number(target.sequence))) throw new ApiError();
|
||||
return raw.map(schedule => {
|
||||
if (Number(schedule.route_id) !== route.id || !['weekday', 'saturday', 'sunday', 'holiday'].includes(schedule.day_type)) throw new ApiError();
|
||||
let trips = schedule.trips;
|
||||
if (typeof trips === 'string') { try { trips = JSON.parse(trips); } catch { throw new ApiError(); } }
|
||||
if (!Array.isArray(trips) || !trips.length) {
|
||||
if (!Array.isArray(schedule.departure_times)) throw new ApiError();
|
||||
trips = schedule.departure_times.map(departureTime => ({ departureTime }));
|
||||
}
|
||||
const normalized = trips.flatMap(trip => {
|
||||
const time = typeof trip?.departureTime === 'string' && /^([01]?\d|2[0-3]):([0-5]\d)(?::([0-5]\d))?$/.exec(trip.departureTime);
|
||||
if (!time) throw new ApiError();
|
||||
const start = trip.startStopId ? stops.get(Number(trip.startStopId)) : null;
|
||||
const end = trip.endStopId ? stops.get(Number(trip.endStopId)) : null;
|
||||
if (trip.startStopId && !start || trip.endStopId && !end) throw new ApiError();
|
||||
const sequence = Number(target.sequence);
|
||||
const reverse = start && end && Number(start.sequence) > Number(end.sequence);
|
||||
if (reverse ? sequence > Number(start.sequence) || sequence < Number(end.sequence)
|
||||
: start && sequence < Number(start.sequence) || end && sequence > Number(end.sequence)) return [];
|
||||
const offset = start && Number(start.sequence) > sequence ? Number(start.time_offset_minutes || 0) - Number(target.time_offset_minutes || 0)
|
||||
: Number(target.time_offset_minutes || 0) - Number(start?.time_offset_minutes || 0);
|
||||
if (!Number.isFinite(offset) || offset < 0 || offset >= 1440) throw new ApiError();
|
||||
const days = value => {
|
||||
if (value == null) return [];
|
||||
if (!Array.isArray(value) || value.some(day => !Number.isInteger(Number(day)) || Number(day) < 1 || Number(day) > 7)) throw new ApiError();
|
||||
return [...new Set(value.map(Number))];
|
||||
};
|
||||
const terminal = end || [...rawStops].sort((a, b) => Number(b.sequence) - Number(a.sequence))[0];
|
||||
return [{ arrivalSeconds: Number(time[1]) * 3600 + Number(time[2]) * 60 + Number(time[3] || 0) + offset * 60,
|
||||
destination: terminal?.name?.trim() || route.name.split(/\s+[-–—]\s+/).at(-1) || route.name,
|
||||
note: typeof trip.note === 'string' ? trip.note.trim() : '', daysOfWeek: days(trip.daysOfWeek), exceptDaysOfWeek: days(trip.exceptDaysOfWeek) }];
|
||||
});
|
||||
const date = value => {
|
||||
if (value == null) return null;
|
||||
const result = String(value).slice(0, 10);
|
||||
if (!validDate(result)) throw new ApiError();
|
||||
return result;
|
||||
};
|
||||
return { dayType: schedule.day_type, validFrom: date(schedule.valid_from), validUntil: date(schedule.valid_until), trips: normalized };
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadTimetable({ upstream, cityId, cityName, stop, now }) {
|
||||
if (!Array.isArray(stop.routes)) throw new ApiError();
|
||||
const date = localDate(now);
|
||||
const calendarYears = [...new Set([shiftDate(date, -1), shiftDate(date, 8)].map(value => Number(value.slice(0, 4))))];
|
||||
const calendars = await Promise.all(calendarYears.map(year => upstream(`api/v1/holidays/year/${year}`)));
|
||||
const holidays = calendars.flatMap(raw => {
|
||||
if (!Array.isArray(raw)) throw new ApiError();
|
||||
return raw.map(item => { const date = String(item.date).slice(0, 10); if (!validDate(date)) throw new ApiError(); return date; });
|
||||
});
|
||||
const routes = stop.routes.filter(route => route.is_active !== false).map(route => ({ id: Number(route.id), number: route.route_number?.trim(), name: route.name?.trim(), type: route.type === 'minibus' ? 'minibus' : 'bus' }));
|
||||
if (routes.some(route => !Number.isSafeInteger(route.id) || !route.number || !route.name)) throw new ApiError();
|
||||
// Bound fan-out. Publish only a complete bundle; one failed route retains the previous client cache.
|
||||
const result = new Array(routes.length);
|
||||
let index = 0;
|
||||
await Promise.all(Array.from({ length: Math.min(4, routes.length) }, async () => {
|
||||
while (index < routes.length) {
|
||||
const current = index++, route = routes[current];
|
||||
const [schedules, stops] = await Promise.all([upstream(`api/v1/routes/${route.id}/schedules`), upstream(`api/v1/routes/${route.id}/stops`)]);
|
||||
result[current] = { ...route, schedules: normalizeSchedules(schedules, route, stops, Number(stop.id)) };
|
||||
}
|
||||
}));
|
||||
return { version: 1, cityId, cityName, stop: { id: Number(stop.id), name: stop.name.trim(), direction: stop.description?.trim() || '' },
|
||||
routes: result, holidays: [...new Set(holidays)], calendarYears, updatedAt: new Date(now).toISOString(), expiresAt: new Date(now + TIMETABLE_MAX_MS).toISOString() };
|
||||
}
|
||||
39
server/weather.mjs
Normal file
@@ -0,0 +1,39 @@
|
||||
import { ApiError } from './board.mjs';
|
||||
import { isWeather } from '../src/lib/weather.mjs';
|
||||
|
||||
export function createWeatherClient({ fetchData = fetch, now = Date.now, apiKey = process.env.OPEN_METEO_API_KEY } = {}) {
|
||||
const cache = new Map(), pending = new Map(), failures = new Map();
|
||||
return {
|
||||
async weather(cityId, stopId, coordinates) {
|
||||
const latitude = Number(coordinates.latitude), longitude = Number(coordinates.longitude);
|
||||
if (coordinates.latitude == null || coordinates.longitude == null || !Number.isFinite(latitude) || Math.abs(latitude) > 90 || !Number.isFinite(longitude) || Math.abs(longitude) > 180) throw new ApiError(503, 'coordinates_unavailable');
|
||||
const key = `${cityId}:${stopId}:${latitude.toFixed(4)}:${longitude.toFixed(4)}`;
|
||||
if (cache.get(key)?.expires > now()) return cache.get(key).data;
|
||||
if (pending.has(key)) return pending.get(key);
|
||||
if (failures.get(key) > now()) throw new ApiError(502, 'weather_unavailable');
|
||||
const request = (async () => {
|
||||
try {
|
||||
const url = new URL(apiKey ? 'https://customer-api.open-meteo.com/v1/forecast' : 'https://api.open-meteo.com/v1/forecast');
|
||||
url.search = new URLSearchParams({ latitude: String(latitude), longitude: String(longitude), timezone: 'Europe/Minsk', timeformat: 'unixtime',
|
||||
current: 'temperature_2m,apparent_temperature,weather_code,is_day,wind_speed_10m',
|
||||
hourly: 'temperature_2m,weather_code,precipitation_probability', forecast_days: '2', wind_speed_unit: 'ms', ...(apiKey ? { apikey: apiKey } : {}) }).toString();
|
||||
// No transit credentials are sent to a weather provider.
|
||||
const response = await fetchData(url, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(10000), redirect: 'error' });
|
||||
if (!response.ok) throw new ApiError();
|
||||
const raw = await response.json(), current = raw.current, hourly = raw.hourly;
|
||||
if (!current || !Array.isArray(hourly?.time)) throw new ApiError();
|
||||
const instant = seconds => typeof seconds === 'number' && Number.isFinite(seconds) ? new Date(seconds * 1000).toISOString() : '';
|
||||
const data = { cityId, stopId, updatedAt: new Date(now()).toISOString(), observedAt: instant(current.time), temperature: current.temperature_2m,
|
||||
apparentTemperature: current.apparent_temperature, code: current.weather_code, isDay: current.is_day === 1, windSpeed: current.wind_speed_10m,
|
||||
hourly: hourly.time.map((time, index) => ({ at: instant(time), temperature: hourly.temperature_2m?.[index], code: hourly.weather_code?.[index], precipitationProbability: hourly.precipitation_probability?.[index] })) };
|
||||
if (!isWeather(data, cityId, stopId)) throw new ApiError();
|
||||
cache.set(key, { data, expires: now() + 600000 }); failures.delete(key);
|
||||
return data;
|
||||
} catch { failures.set(key, now() + 60000); throw new ApiError(502, 'weather_unavailable'); }
|
||||
finally { pending.delete(key); }
|
||||
})();
|
||||
pending.set(key, request);
|
||||
return request;
|
||||
},
|
||||
};
|
||||
}
|
||||
220
src/App.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { flushSync } from 'react-dom';
|
||||
import { ArrowDown, ArrowRight, ArrowUpRight, BusFront, Check, Clock, Fullscreen, Info, Pause, Play, Refresh, SettingsIcon, Shield, WifiOff } from './Icons';
|
||||
import { NumericText } from './NumericText';
|
||||
import { RouteBadge } from './RouteBadge';
|
||||
import { FittedDestination } from './FittedDestination';
|
||||
import { useIdleControls } from './useIdleControls';
|
||||
import { PassengerQr } from './PassengerQr';
|
||||
import { WeatherCard, WeatherSummary } from './WeatherBlock';
|
||||
import { localDate, timetableAvailable, timetableDepartures } from './lib/timetable.mjs';
|
||||
import logo from './assets/ridebus-logo.svg?raw';
|
||||
import { colorScheme, departurePage, stopDirection } from './lib/presentation.mjs';
|
||||
import type { CSSProperties } from 'react';
|
||||
import type { Alert, BoardData, Config, Departure, Scene, Settings } from './types';
|
||||
import { activeAlerts, isFresh, minutesUntil, shouldHoldScene, upcomingDepartures } from './lib/board.mjs';
|
||||
import { hasSavedSettings, readSettings, saveSettings } from './lib/storage';
|
||||
import { useBoard, useClock, useTimetable, useWeather, fallbackConfig } from './hooks';
|
||||
import { SettingsPanel } from './SettingsPanel';
|
||||
|
||||
const clockFormat = new Intl.DateTimeFormat('ru', { timeZone: 'Europe/Minsk', hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
const dateFormat = new Intl.DateTimeFormat('ru', { timeZone: 'Europe/Minsk', weekday: 'long', day: 'numeric', month: 'long' });
|
||||
const timeFormat = new Intl.DateTimeFormat('ru', { timeZone: 'Europe/Minsk', hour: '2-digit', minute: '2-digit' });
|
||||
const sceneNames: Record<Scene, string> = { departures: 'Ближайшие рейсы', info: 'Информация', ad: 'Реклама' };
|
||||
|
||||
function Countdown({ departure, now, large = false }: { departure: Departure; now: number; large?: boolean }) {
|
||||
const minutes = minutesUntil(departure, now);
|
||||
if (localDate(Date.parse(departure.scheduledAt)) > localDate(now)) return <div className={`countdown countdown-future ${large ? 'countdown-large' : ''}`} aria-label={`Завтра в ${departure.scheduledTime} по расписанию`}><small>завтра</small><span><NumericText value={departure.scheduledTime} /></span></div>;
|
||||
return <div className={`countdown ${large ? 'countdown-large' : ''}`} aria-label={minutes > 0 ? `Через ${minutes} минут по расписанию` : 'Сейчас по расписанию'}>
|
||||
<span className={minutes === 0 ? 'countdown-now' : ''}>{minutes > 0 ? <NumericText value={minutes} /> : 'сейчас'}</span>{minutes > 0 && <small>мин</small>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function AlertCard({ alert, compact = false }: { alert: Alert; compact?: boolean }) {
|
||||
return <article className={`notice ${alert.type !== 'info' ? 'notice-warning' : ''} ${compact ? 'notice-compact' : ''}`}>
|
||||
<div className="notice-title"><Info size={21} /><span>{alert.type === 'cancellation' ? 'Отмена рейсов' : 'Изменения движения'}</span></div>
|
||||
{!!alert.routeNumbers.length && <div className="notice-routes">{alert.routeNumbers.map(number => `№${number}`).join(' · ')}</div>}
|
||||
<h3>{alert.title}</h3><p>{alert.message}</p>
|
||||
</article>;
|
||||
}
|
||||
|
||||
function DeparturesScreen({ data, departures, now, alerts, loading, rowCount, fresh, error, paused }: {
|
||||
data: BoardData | null; departures: Departure[]; now: number; alerts: Alert[]; loading: boolean; rowCount: number; fresh: boolean; error: string; paused: boolean;
|
||||
}) {
|
||||
const first = departures[0];
|
||||
const table = useRef<HTMLDivElement>(null);
|
||||
const [tableHeight, setTableHeight] = useState(360);
|
||||
const [page, setPage] = useState(0);
|
||||
const capacity = Math.max(2, Math.floor(tableHeight / 50));
|
||||
const visible: { items: Departure[]; start: number; count: number; pages: number; size: number } = departurePage(departures.slice(1), rowCount, capacity, page);
|
||||
const rows = Math.max(1, Math.min(rowCount, visible.size));
|
||||
useLayoutEffect(() => {
|
||||
if (!table.current) return;
|
||||
const observer = new ResizeObserver(entries => setTableHeight(entries[0].contentRect.height));
|
||||
observer.observe(table.current);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (paused || visible.pages < 2) return;
|
||||
const timer = setInterval(() => setPage(value => value + 1), 7000);
|
||||
return () => clearInterval(timer);
|
||||
}, [paused, visible.pages]);
|
||||
return <div className="departure-layout">
|
||||
<section className="departure-content" aria-label="Ближайшие рейсы">
|
||||
<div className="section-heading"><span className="eyebrow">БЛИЖАЙШИЙ РЕЙС</span><span className="source-label"><Clock /> По расписанию{data?.mode === 'demo' ? ' · демо' : ''}</span></div>
|
||||
{first ? <div className="hero-departure">
|
||||
<RouteBadge number={first.routeNumber} large />
|
||||
<div className="hero-destination"><div className="transport-kind"><BusFront size={19} /> {first.type === 'minibus' ? 'Маршрутное такси' : 'Автобус'}</div><h2>{first.destination}</h2>
|
||||
<div className="hero-details">{first.note || first.routeName}<time className="schedule-time" dateTime={first.scheduledAt}><NumericText value={first.scheduledTime} /></time></div></div>
|
||||
<Countdown departure={first} now={now} large />
|
||||
</div> : <div className="empty-departures"><BusFront size={46} /><h2>{loading ? 'Получаем расписание' : !data ? 'Расписание недоступно' : fresh ? 'На сегодня рейсов больше нет' : 'Сохранённые рейсы закончились'}</h2>
|
||||
<p>{loading ? 'Ближайший транспорт появится здесь' : error === 'api_not_configured' ? 'Подключите RideBus API или включите деморежим в настройках' : !data || !fresh ? 'Ожидаем подключения к RideBus' : 'Новые рейсы появятся с началом движения'}</p></div>}
|
||||
<div className="next-heading"><span className="eyebrow">СЛЕДОМ ПРИБУДУТ</span>{visible.pages > 1 ? <span className="page-indicator">{visible.start + 1}–{visible.start + visible.items.length} из {visible.count}</span> : <ArrowDown size={22} />}</div>
|
||||
<div ref={table} className="departure-table" data-rows={rowCount} data-paged={visible.pages > 1} style={{ '--rows': rows, '--row-height': `${tableHeight / rows}px` } as CSSProperties}>
|
||||
{visible.items.map((departure, index) => <article className="departure-row" key={`position-${index}`}>
|
||||
<RouteBadge number={departure.routeNumber} />
|
||||
<div className="row-destination"><FittedDestination text={departure.destination} rowHeight={tableHeight / rows} /><p>{departure.note || (departure.type === 'minibus' ? 'Маршрутное такси' : 'Автобус')}</p></div>
|
||||
<time dateTime={departure.scheduledAt} className="row-time"><NumericText value={departure.scheduledTime} /></time>
|
||||
<Countdown departure={departure} now={now} />
|
||||
</article>)}
|
||||
{first && departures.length === 1 && <p className="empty-following">Это последний доступный рейс</p>}
|
||||
{!first && <div className="quiet-placeholder"><span /><span /><span /></div>}
|
||||
</div>
|
||||
</section>
|
||||
{alerts[0] && <div className="transport-notice"><Info /><span>{alerts[0].routeNumbers.map(number => `№${number}`).join(' · ')} {alerts[0].title}</span></div>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function InfoScreen({ data, alerts, fresh, seconds, paused, page, onPage, config, timetable, weather, now }: { data: BoardData | null; alerts: Alert[]; fresh: boolean; seconds: number; paused: boolean; page: number; onPage: (page: number) => void;
|
||||
config: Config; timetable: ReturnType<typeof useTimetable>; weather: ReturnType<typeof useWeather>; now: number }) {
|
||||
const panel = useRef<HTMLElement>(null);
|
||||
useEffect(() => {
|
||||
const element = panel.current;
|
||||
if (!element || paused || matchMedia('(prefers-reduced-motion: reduce)').matches) return;
|
||||
let timer: ReturnType<typeof setInterval>;
|
||||
const start = setTimeout(() => {
|
||||
const overflow = element.scrollHeight - element.clientHeight;
|
||||
if (overflow > 0) timer = setInterval(() => { element.scrollTop += overflow / (Math.max(1, seconds - 7) * 10); }, 100);
|
||||
}, 3500);
|
||||
return () => { clearTimeout(start); clearInterval(timer); };
|
||||
}, [seconds, paused, page]);
|
||||
return <section ref={panel} className="info-screen" aria-label="Информация для пассажиров">
|
||||
<div className="info-intro"><h2>{alerts.length ? 'Изменения движения' : 'Пока вы ждёте.'}</h2>
|
||||
{!alerts.length && <nav className="information-tabs" aria-label="Информация на вертикальном экране">{['Маршруты', 'Погода', 'RideBus'].map((name, index) => <button key={name} aria-current={page % 3 === index ? 'page' : undefined} onClick={() => onPage(index)}>{name}</button>)}</nav>}</div>
|
||||
{alerts.length ? <div className="info-notices">{alerts.map(alert => <AlertCard key={alert.id} alert={alert} />)}</div> : <div className="info-grid">
|
||||
<section className="info-card routes-info" data-active={page % 3 === 0}><div className="card-icon"><BusFront /></div><h3>Маршруты остановки</h3><div className="route-cloud">{[...new Set(data?.routes.map(route => route.number))].map(number => <span key={number}>{number}</span>)}</div><div className="cache-caption"><Check />{timetableAvailable(timetable.data, now) && timetable.saved ? `Полное расписание сохранено · маршрутов: ${timetable.data?.routes.length}` : timetable.loading ? 'Сохраняем полное расписание…' : timetable.saved === false ? 'Не удалось сохранить полный кеш' : 'Полный кеш сейчас недоступен'}</div><p className="schedule-explanation">Время по расписанию. Фактическое прибытие зависит от дорожной обстановки.</p></section>
|
||||
<section className="info-card weather-info" data-active={page % 3 === 1}><WeatherCard data={weather.data} now={now} loading={weather.loading} failed={weather.failed} demo={data?.mode === 'demo'} /></section>
|
||||
<section className="info-card qr-info" data-active={page % 3 === 2}><PassengerQr config={config} stop={data?.stop} /></section>
|
||||
</div>}
|
||||
{!alerts.length && <div className="info-status"><Shield /><span>{data?.alertsAvailable === false || !fresh ? 'Актуальные предупреждения сейчас недоступны' : 'Нет активных предупреждений для этой остановки'}</span></div>}
|
||||
</section>;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [settings, setSettings] = useState<Settings>(() => {
|
||||
const saved = readSettings();
|
||||
if (new URLSearchParams(location.search).get('demo') === '1') saved.demo = true;
|
||||
return saved;
|
||||
});
|
||||
const [config, setConfig] = useState(fallbackConfig);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const controlsVisible = useIdleControls(settingsOpen);
|
||||
const [scene, setScene] = useState<Scene>('departures');
|
||||
const [infoPage, setInfoPage] = useState(0);
|
||||
const [paused, setPaused] = useState(false);
|
||||
const [startedAt, setStartedAt] = useState(Date.now());
|
||||
const [notice, setNotice] = useState('');
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
const noticeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const { data: liveData, failed, loading, offset, error, offlineSaved, refresh } = useBoard(settings, config);
|
||||
const timetable = useTimetable(settings, config), weather = useWeather(settings, config);
|
||||
const now = useClock(offset);
|
||||
const date = localDate(now);
|
||||
const fullDepartures = useMemo(() => timetableDepartures(timetable.data, date) as Departure[], [timetable.data, date]);
|
||||
const fullAvailable = !settings.demo && timetableAvailable(timetable.data, now);
|
||||
const data: BoardData | null = liveData || (fullAvailable && timetable.data ? { ...timetable.data, departures: fullDepartures, alerts: [], alertsAvailable: false, partial: false, serviceDate: date, mode: 'live' } : null);
|
||||
const departures: Departure[] = fullAvailable ? fullDepartures.filter(item => Date.parse(item.scheduledAt) + 60000 > now && (!settings.routeFilter || String(item.routeId) === settings.routeFilter)) : upcomingDepartures(data, now, settings.routeFilter);
|
||||
const alerts: Alert[] = activeAlerts(data?.alerts || [], now, settings.routeFilter, data?.routes || []);
|
||||
const fresh = settings.demo || isFresh(data, now, failed);
|
||||
const held = shouldHoldScene(settings, departures[0], now);
|
||||
const urgent = alerts.some(alert => alert.type === 'cancellation' || alert.type === 'detour');
|
||||
const scenes: Scene[] = settings.adsEnabled && !urgent ? ['departures', 'info', 'ad'] : ['departures', 'info'];
|
||||
const seconds = scene === 'departures' ? settings.mainSeconds : scene === 'info' ? Math.max(settings.infoSeconds, Math.min(60, alerts.reduce((sum, alert) => sum + alert.message.length, 0) / 18)) : settings.adSeconds;
|
||||
const elapsed = Date.now() - startedAt;
|
||||
const progress = Math.min(1, elapsed / (seconds * 1000));
|
||||
const sceneState = useRef({ scenes, scene, held, settingsOpen, paused });
|
||||
sceneState.current = { scenes, scene, held, settingsOpen, paused };
|
||||
const showNotice = useCallback((text: string) => {
|
||||
setNotice(text); if (noticeTimer.current) clearTimeout(noticeTimer.current);
|
||||
noticeTimer.current = setTimeout(() => setNotice(''), 4000);
|
||||
}, []);
|
||||
const goTo = useCallback((next: Scene) => {
|
||||
if (sceneState.current.held && next !== 'departures') { showNotice('Главный экран удерживается: до ближайшего рейса не больше 2 минут'); return; }
|
||||
const change = () => { if (sceneState.current.scene === 'info' && next !== 'info') setInfoPage(value => value + 1); setScene(next); setStartedAt(Date.now()); };
|
||||
if (document.startViewTransition && !matchMedia('(prefers-reduced-motion: reduce)').matches) document.startViewTransition(() => flushSync(change));
|
||||
else change();
|
||||
}, [showNotice]);
|
||||
const nextScene = useCallback(() => {
|
||||
const state = sceneState.current;
|
||||
goTo(state.scenes[(state.scenes.indexOf(state.scene) + 1) % state.scenes.length]);
|
||||
}, [goTo]);
|
||||
const toggleFullscreen = useCallback(async () => {
|
||||
try { if (document.fullscreenElement) await document.exitFullscreen(); else await document.documentElement.requestFullscreen(); }
|
||||
catch { showNotice('Полный экран недоступен в этом браузере'); }
|
||||
}, [showNotice]);
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
fetch('/api/config', { signal: controller.signal }).then(response => { if (!response.ok) throw new Error(); return response.json(); }).then((value: Config) => {
|
||||
setConfig(value);
|
||||
if (!hasSavedSettings() && new URLSearchParams(location.search).get('demo') !== '1') setSettings(previous => ({ ...previous, stopId: value.defaultStopId, demo: !value.configured }));
|
||||
}).catch(() => {});
|
||||
return () => controller.abort();
|
||||
}, []);
|
||||
useEffect(() => { document.title = `${data?.stop.name || config.stopName} · RideBus Board`; }, [data?.stop.name, config.stopName]);
|
||||
useEffect(() => {
|
||||
if (held) { setScene('departures'); setStartedAt(Date.now()); }
|
||||
else setStartedAt(Date.now());
|
||||
}, [held]);
|
||||
useEffect(() => { if (urgent && scene === 'ad') { setScene('info'); setStartedAt(Date.now()); } }, [urgent, scene]);
|
||||
useEffect(() => {
|
||||
if (paused || settingsOpen) { setStartedAt(Date.now()); return; }
|
||||
if (!held && elapsed >= seconds * 1000) nextScene();
|
||||
}, [now, held, paused, settingsOpen, elapsed, seconds, nextScene]);
|
||||
useEffect(() => {
|
||||
function keydown(event: KeyboardEvent) {
|
||||
if (event.target instanceof HTMLElement && (['INPUT', 'SELECT', 'TEXTAREA', 'BUTTON'].includes(event.target.tagName) || event.target.isContentEditable)) return;
|
||||
if (event.code === 'KeyS') { event.preventDefault(); setSettingsOpen(value => !value); }
|
||||
if (settingsOpen) return;
|
||||
if (event.code === 'KeyF') { event.preventDefault(); void toggleFullscreen(); }
|
||||
if (event.code === 'Space') { event.preventDefault(); setPaused(value => !value); setStartedAt(Date.now()); }
|
||||
if (event.code === 'ArrowRight') { event.preventDefault(); nextScene(); }
|
||||
}
|
||||
const changed = () => setFullscreen(Boolean(document.fullscreenElement));
|
||||
document.addEventListener('keydown', keydown); document.addEventListener('fullscreenchange', changed);
|
||||
return () => { document.removeEventListener('keydown', keydown); document.removeEventListener('fullscreenchange', changed); };
|
||||
}, [settingsOpen, toggleFullscreen, nextScene]);
|
||||
useEffect(() => () => { if (noticeTimer.current) clearTimeout(noticeTimer.current); }, []);
|
||||
const timeParts = clockFormat.format(now).split(':');
|
||||
const updateLabel = fullAvailable && timetable.data ? timeFormat.format(Date.parse(timetable.data.updatedAt)) : data ? timeFormat.format(Date.parse(data.updatedAt)) : '—';
|
||||
const direction = stopDirection(data?.stop);
|
||||
return <div className={`board ${scene !== 'departures' ? 'board-secondary' : ''} ${controlsVisible ? '' : 'controls-idle'}`} data-row-count={settings.rowCount} style={{ '--accent': colorScheme(settings.colorScheme).color } as CSSProperties}>
|
||||
<header className="board-header"><div className="stop-heading"><h1>{data?.stop.name || config.stopName}</h1>
|
||||
<div className="stop-meta">{direction && <div className="stop-direction"><ArrowRight /><span>{direction}</span></div>}<WeatherSummary data={weather.data} now={now} failed={weather.failed} /></div></div>
|
||||
<div className="header-right"><div className="brand" aria-label="RideBus"><span className="official-mark" aria-hidden="true" dangerouslySetInnerHTML={{ __html: logo }} /><span className="brand-word">ridebus</span></div><div className="clock" aria-label={`Текущее время ${clockFormat.format(now)}`}><NumericText value={`${timeParts[0]}:${timeParts[1]}`} /><small><NumericText value={timeParts[2]} /></small></div><div className="today">{dateFormat.format(now)}</div></div>
|
||||
</header>
|
||||
{!fresh && !settings.demo && <div className="offline-banner" role="status"><WifiOff /><span>{data ? `Нет свежих данных. Сохранённое расписание · обновлено в ${updateLabel}` : loading ? 'Подключаемся к RideBus…' : 'Не удалось загрузить расписание. Проверьте подключение.'}</span><button onClick={refresh} aria-label="Повторить подключение"><Refresh /></button></div>}
|
||||
<main className="scene-container"><div className="scene" key={scene} data-scene={scene}>
|
||||
{scene === 'departures' && <DeparturesScreen data={data} departures={departures} now={now} alerts={alerts} loading={loading} rowCount={settings.rowCount} fresh={fresh} error={error} paused={paused || settingsOpen} />}
|
||||
{scene === 'info' && <InfoScreen data={data} alerts={alerts} fresh={fresh} seconds={seconds} paused={paused || settingsOpen} page={infoPage} onPage={setInfoPage} config={config} timetable={timetable} weather={weather} now={now} />}
|
||||
{scene === 'ad' && <section className="ad-screen"><span className="eyebrow">РЕКЛАМА · ПРЕДПРОСМОТР</span><h2>{settings.adTitle}</h2><p>{settings.adText}</p><div className="ad-signature">Время для нового.<ArrowUpRight /></div></section>}
|
||||
</div></main>
|
||||
{scene !== 'departures' && <div className="persistent-next"><span className="eyebrow">БЛИЖАЙШИЙ РЕЙС</span>{departures[0] ? <><RouteBadge number={departures[0].routeNumber} /><strong>{departures[0].destination}</strong><Countdown departure={departures[0]} now={now} /><span className="source-label">По расписанию</span></> : <span>Нет доступных рейсов</span>}</div>}
|
||||
<footer className="board-footer"><div className="cycle-progress"><span style={{ transform: `scaleX(${held || paused || settingsOpen ? 0 : progress})` }} /></div>
|
||||
<div className="footer-main"><nav className="scene-navigation" aria-label="Экраны табло">{scenes.map((item, index) => <button key={item} onClick={() => goTo(item)} className={scene === item ? 'active' : ''} aria-label={`0${index + 1} ${sceneNames[item]}`} title={sceneNames[item]} aria-current={scene === item ? 'page' : undefined}><span className="scene-index">0{index + 1}</span><span>{sceneNames[item]}</span></button>)}</nav>
|
||||
<div className="footer-status">{!fresh && !settings.demo && <WifiOff />}<span>{settings.demo ? 'Демонстрация' : held ? 'Приоритет транспорта' : `Обновлено в ${updateLabel}`}{data?.partial ? ' · часть рейсов недоступна' : ''}{offlineSaved === false ? ' · сохранение недоступно' : ''}</span></div>
|
||||
<div className="board-controls"><button className="icon-button" onClick={() => { setPaused(value => !value); setStartedAt(Date.now()); }} aria-label={paused ? 'Продолжить смену экранов' : 'Приостановить смену экранов'} title="Пауза · пробел">{paused ? <Play /> : <Pause />}</button><button className="icon-button" onClick={nextScene} aria-label="Следующий экран" title="Следующий экран · →"><ArrowRight /></button><button className="icon-button" onClick={() => void toggleFullscreen()} aria-label={fullscreen ? 'Выйти из полного экрана' : 'На весь экран'} title="Полный экран · F"><Fullscreen /></button><button className="icon-button" onClick={() => setSettingsOpen(true)} aria-label="Настройки табло" title="Настройки · S"><SettingsIcon /></button></div></div>
|
||||
</footer>
|
||||
{notice && <div className="toast" role="status">{notice}</div>}
|
||||
{settingsOpen && <SettingsPanel settings={settings} config={config} data={data} onClose={() => { setSettingsOpen(false); setStartedAt(Date.now()); }} onFullscreen={() => void toggleFullscreen()} onSave={value => { setSettings(value); saveSettings(value); setScene('departures'); setStartedAt(Date.now()); setSettingsOpen(false); showNotice('Настройки сохранены'); }} />}
|
||||
</div>;
|
||||
}
|
||||
30
src/FittedDestination.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { useLayoutEffect, useRef } from 'react';
|
||||
|
||||
export function FittedDestination({ text, rowHeight }: { text: string; rowHeight: number }) {
|
||||
const heading = useRef<HTMLHeadingElement>(null);
|
||||
useLayoutEffect(() => {
|
||||
const element = heading.current, container = element?.parentElement;
|
||||
if (!element || !container) return;
|
||||
const fit = () => {
|
||||
element.style.fontSize = '';
|
||||
const base = parseFloat(getComputedStyle(element).fontSize);
|
||||
const details = container.querySelector('p');
|
||||
const detailsHeight = details && getComputedStyle(details).display !== 'none' ? details.getBoundingClientRect().height + 5 : 0;
|
||||
const available = Math.max(1, container.getBoundingClientRect().height - 6 - detailsHeight);
|
||||
if (element.getBoundingClientRect().height <= available) return;
|
||||
let low = Math.min(16, base), high = base;
|
||||
for (let attempt = 0; attempt < 7; attempt++) {
|
||||
const size = (low + high) / 2;
|
||||
element.style.fontSize = `${size}px`;
|
||||
if (element.getBoundingClientRect().height > available) high = size; else low = size;
|
||||
}
|
||||
element.style.fontSize = `${low}px`;
|
||||
};
|
||||
const observer = new ResizeObserver(fit);
|
||||
observer.observe(container); fit();
|
||||
let active = true;
|
||||
void document.fonts.ready.then(() => { if (active) fit(); });
|
||||
return () => { active = false; observer.disconnect(); };
|
||||
}, [text, rowHeight]);
|
||||
return <h3 ref={heading}>{text}</h3>;
|
||||
}
|
||||
40
src/Icons.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import {
|
||||
Icon20ArrowDownOutline, Icon20ArrowRightOutline, Icon20ArrowUpRightOutline,
|
||||
Icon20BusOutline, Icon20Check, Icon20ClockOutline, Icon20GlobeCrossOutline,
|
||||
Icon20RefreshOutline, Icon24Cancel, Icon28SettingsOutline, Icon20Pause,
|
||||
Icon20Play, Icon20FullscreenOutline, Icon20InfoCircleOutline,
|
||||
Icon20HelpOutline, Icon20CheckShieldOutline, Icon20ArrowUturnLeftOutline,
|
||||
Icon20SunOutline, Icon20MoonOutline, Icon20CloudOutline, Icon16CloudRain, Icon16Snowflake, Icon16CloudLighting, Icon20WindOutline,
|
||||
} from '@vkontakte/icons';
|
||||
import type { ComponentType } from 'react';
|
||||
|
||||
type IconProps = { size?: number; className?: string };
|
||||
function adapt(Icon: ComponentType<{ width?: number; height?: number; className?: string }>) {
|
||||
return function BoardIcon({ size = 24, className }: IconProps) {
|
||||
return <Icon width={size} height={size} className={className} aria-hidden="true" />;
|
||||
};
|
||||
}
|
||||
export const ArrowDown = adapt(Icon20ArrowDownOutline);
|
||||
export const ArrowRight = adapt(Icon20ArrowRightOutline);
|
||||
export const ArrowUpRight = adapt(Icon20ArrowUpRightOutline);
|
||||
export const BusFront = adapt(Icon20BusOutline);
|
||||
export const Check = adapt(Icon20Check);
|
||||
export const Clock = adapt(Icon20ClockOutline);
|
||||
export const WifiOff = adapt(Icon20GlobeCrossOutline);
|
||||
export const Refresh = adapt(Icon20RefreshOutline);
|
||||
export const Close = adapt(Icon24Cancel);
|
||||
export const SettingsIcon = adapt(Icon28SettingsOutline);
|
||||
export const Pause = adapt(Icon20Pause);
|
||||
export const Play = adapt(Icon20Play);
|
||||
export const Fullscreen = adapt(Icon20FullscreenOutline);
|
||||
export const Info = adapt(Icon20InfoCircleOutline);
|
||||
export const Help = adapt(Icon20HelpOutline);
|
||||
export const Shield = adapt(Icon20CheckShieldOutline);
|
||||
export const Reset = adapt(Icon20ArrowUturnLeftOutline);
|
||||
export const Sun = adapt(Icon20SunOutline);
|
||||
export const Moon = adapt(Icon20MoonOutline);
|
||||
export const Cloud = adapt(Icon20CloudOutline);
|
||||
export const Rain = adapt(Icon16CloudRain);
|
||||
export const Snow = adapt(Icon16Snowflake);
|
||||
export const Storm = adapt(Icon16CloudLighting);
|
||||
export const Wind = adapt(Icon20WindOutline);
|
||||
25
src/NumericText.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { useState } from 'react';
|
||||
import { numericFrame } from './lib/presentation.mjs';
|
||||
|
||||
type Props = { value: string | number; numericValue?: number; className?: string };
|
||||
export function NumericText({ value, numericValue, className = '' }: Props) {
|
||||
const text = String(value);
|
||||
const number = numericValue ?? Number(text.replace(/\D/g, ''));
|
||||
const [frame, setFrame] = useState({ current: text, previous: text, number, previousNumber: number, revision: 0 });
|
||||
if (frame.current !== text) setFrame({ current: text, previous: frame.current, number, previousNumber: frame.number, revision: frame.revision + 1 });
|
||||
const transition = numericFrame(frame.previous, frame.current, frame.previousNumber, frame.number);
|
||||
return <span className={`numeric-text ${className}`} data-value={text}>
|
||||
<span className="sr-only">{text}</span>
|
||||
<span className="numeric-track" aria-hidden="true">
|
||||
{transition.glyphs.map(glyph => {
|
||||
if (!/\d| /.test(glyph.character)) return <span key={`symbol-${glyph.position}`} className="numeric-symbol">{glyph.character}</span>;
|
||||
const changed = glyph.changed && frame.revision > 0;
|
||||
return <span className={`numeric-glyph ${glyph.character === ' ' ? 'numeric-vanish' : ''}`} data-direction={transition.direction}
|
||||
key={changed ? `${glyph.position}-${frame.revision}` : `still-${glyph.position}-${glyph.character}`} data-rolling={changed ? 'true' : undefined}>
|
||||
{changed && <span className="numeric-old">{glyph.previous}</span>}
|
||||
<span className="numeric-current">{glyph.character}</span>
|
||||
</span>;
|
||||
})}
|
||||
</span>
|
||||
</span>;
|
||||
}
|
||||
19
src/PassengerQr.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { useMemo } from 'react';
|
||||
import QRCode from 'qrcode';
|
||||
import { ArrowUpRight } from './Icons';
|
||||
import type { Config, Stop } from './types';
|
||||
import { passengerLink } from './lib/passenger.mjs';
|
||||
|
||||
export function PassengerQr({ config, stop }: { config: Config; stop: Stop | undefined }) {
|
||||
const url = passengerLink(config.passengerBaseUrl, config.cityId, stop?.id || config.defaultStopId);
|
||||
const qr = useMemo(() => {
|
||||
const code = QRCode.create(url, { errorCorrectionLevel: 'M' }), size = code.modules.size;
|
||||
let path = '';
|
||||
for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) if (code.modules.get(y, x)) path += `M${x + 4} ${y + 4}h1v1h-1z`;
|
||||
return { path, size: size + 8 };
|
||||
}, [url]);
|
||||
return <><h3>Расписание в телефоне</h3><div className="qr-content"><a href={url} target="_blank" rel="noreferrer" aria-label={`Открыть ${stop?.name || config.stopName} в RideBus`}>
|
||||
<svg className="passenger-qr" viewBox={`0 0 ${qr.size} ${qr.size}`} role="img" aria-label="QR-код расписания этой остановки" shapeRendering="crispEdges">
|
||||
<rect width={qr.size} height={qr.size} fill="var(--accent)" /><path d={qr.path} fill="#000000" />
|
||||
</svg></a><p>Наведите камеру на QR-код</p></div><a className="passenger-link" href={url} target="_blank" rel="noreferrer">{new URL(url).host}<ArrowUpRight size={20} /></a></>;
|
||||
}
|
||||
26
src/RouteBadge.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { useLayoutEffect, useRef, useState } from 'react';
|
||||
import { NumericText } from './NumericText';
|
||||
|
||||
export function RouteBadge({ number, large = false }: { number: string; large?: boolean }) {
|
||||
const badge = useRef<HTMLSpanElement>(null);
|
||||
const label = useRef<HTMLSpanElement>(null);
|
||||
const [scale, setScale] = useState(1);
|
||||
useLayoutEffect(() => {
|
||||
const element = badge.current, content = label.current;
|
||||
if (!element || !content) return;
|
||||
let mounted = true;
|
||||
const fit = () => {
|
||||
if (!mounted) return;
|
||||
const css = getComputedStyle(element);
|
||||
const available = element.clientWidth - parseFloat(css.paddingLeft) - parseFloat(css.paddingRight);
|
||||
setScale(Math.min(1, available / Math.max(1, content.scrollWidth)));
|
||||
};
|
||||
const observer = new ResizeObserver(fit);
|
||||
observer.observe(element);
|
||||
fit(); void document.fonts.ready.then(fit);
|
||||
return () => { mounted = false; observer.disconnect(); };
|
||||
}, [number]);
|
||||
return <span ref={badge} className={`route-badge ${large ? 'route-badge-large' : ''}`}>
|
||||
<span ref={label} className="route-badge-label" style={{ transform: `scale(${scale})` }}><NumericText value={number} /></span>
|
||||
</span>;
|
||||
}
|
||||
61
src/SettingsPanel.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { ArrowUpRight, Check, Fullscreen, Reset, Close } from './Icons';
|
||||
import { colorSchemes } from './lib/presentation.mjs';
|
||||
import type { BoardData, Config, Settings, Stop } from './types';
|
||||
|
||||
export function SettingsPanel({ settings, config, data, onSave, onClose, onFullscreen }: {
|
||||
settings: Settings; config: Config; data: BoardData | null; onSave: (value: Settings) => void; onClose: () => void; onFullscreen: () => void;
|
||||
}) {
|
||||
const [draft, setDraft] = useState(settings);
|
||||
const [stops, setStops] = useState<Stop[]>([]);
|
||||
const [stopsError, setStopsError] = useState(false);
|
||||
const dialog = useRef<HTMLDialogElement>(null);
|
||||
useEffect(() => { dialog.current?.showModal(); }, []);
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
fetch('/api/stops', { signal: controller.signal }).then(response => { if (!response.ok) throw new Error(); return response.json(); })
|
||||
.then(setStops).catch(() => { if (!controller.signal.aborted) setStopsError(true); });
|
||||
return () => controller.abort();
|
||||
}, []);
|
||||
const update = <K extends keyof Settings>(key: K, value: Settings[K]) => setDraft(previous => ({ ...previous, [key]: value }));
|
||||
return <dialog ref={dialog} className="settings-dialog" onCancel={onClose} aria-labelledby="settings-title">
|
||||
<form onSubmit={event => { event.preventDefault(); onSave(draft); }}>
|
||||
<div className="settings-heading"><div><div className="eyebrow">RIDEBUS BOARD</div><h2 id="settings-title">Настройки табло</h2></div><button type="button" className="icon-button" onClick={onClose} aria-label="Закрыть настройки"><Close /></button></div>
|
||||
<div className="settings-body">
|
||||
<section><h3>Остановка</h3>
|
||||
<div className="settings-note">{config.cityName} · данные RideBus API</div>
|
||||
<label>Остановка и площадка<select value={draft.stopId} onChange={event => { update('stopId', Number(event.target.value)); update('routeFilter', ''); }}>
|
||||
{!stops.length && <option value={draft.stopId}>{data?.stop.name || config.stopName}</option>}
|
||||
{stops.map(stop => <option key={stop.id} value={stop.id}>{stop.name}{stop.direction ? ` · ${stop.direction}` : ''}</option>)}
|
||||
</select></label>
|
||||
{stopsError && <p className="form-hint">Список остановок сейчас недоступен. Текущая настройка сохранена.</p>}
|
||||
<label>Маршруты на экране<select value={draft.routeFilter} disabled={draft.stopId !== settings.stopId} onChange={event => update('routeFilter', event.target.value)}>
|
||||
<option value="">Все маршруты этой остановки</option>
|
||||
{data?.routes.map(route => <option key={route.id} value={route.id}>№{route.number} · {route.name}</option>)}
|
||||
</select></label>
|
||||
<label className="switch-row"><span>Демонстрационные данные<small>Для проверки оформления без API</small></span><input type="checkbox" checked={draft.demo} onChange={event => update('demo', event.target.checked)} /></label>
|
||||
</section>
|
||||
<section><h3>Цветовая схема</h3>
|
||||
<fieldset className="theme-picker"><legend className="sr-only">Контрастный цвет на чёрном фоне</legend>
|
||||
{colorSchemes.map(scheme => <label className="theme-option" key={scheme.id}><input type="radio" name="color-scheme" value={scheme.id} checked={draft.colorScheme === scheme.id} onChange={() => update('colorScheme', scheme.id)} /><span className="theme-swatch" style={{ background: scheme.color }} /><span>{scheme.name}</span></label>)}
|
||||
</fieldset><p className="form-hint">Фон остаётся чёрным. Меняются акценты, номера маршрутов и логотип.</p>
|
||||
</section>
|
||||
<section><h3>Экраны и переходы</h3>
|
||||
<div className="form-grid"><label>Главный экран, сек<input type="number" min="5" max="120" required value={draft.mainSeconds} onChange={event => update('mainSeconds', Number(event.target.value))} /></label>
|
||||
<label>Информация, сек<input type="number" min="5" max="60" required value={draft.infoSeconds} onChange={event => update('infoSeconds', Number(event.target.value))} /></label></div>
|
||||
<label>Следующие рейсы<select value={draft.rowCount} onChange={event => update('rowCount', Number(event.target.value))}>{[2,3,4,5,6].map(number => <option key={number} value={number}>{number}</option>)}</select></label>
|
||||
<label className="switch-row"><span>Удерживать главный экран<small>Когда до рейса осталось не больше 2 минут</small></span><input type="checkbox" checked={draft.holdNear} onChange={event => update('holdNear', event.target.checked)} /></label>
|
||||
</section>
|
||||
<section><h3>Рекламный экран <span className="tiny-tag">ПРОТОТИП</span></h3>
|
||||
<label className="switch-row"><span>Добавить в цикл<small>По умолчанию отключён</small></span><input type="checkbox" checked={draft.adsEnabled} onChange={event => update('adsEnabled', event.target.checked)} /></label>
|
||||
{draft.adsEnabled && <><label>Заголовок<input maxLength={80} required value={draft.adTitle} onChange={event => update('adTitle', event.target.value)} /></label>
|
||||
<label>Текст<textarea maxLength={200} required value={draft.adText} onChange={event => update('adText', event.target.value)} /></label>
|
||||
<label>Длительность, сек<input type="number" min="5" max="30" required value={draft.adSeconds} onChange={event => update('adSeconds', Number(event.target.value))} /></label></>}
|
||||
</section>
|
||||
<button type="button" className="text-button" onClick={onFullscreen}><Fullscreen size={18} /> Полноэкранный режим <ArrowUpRight size={16} /></button>
|
||||
<p className="form-hint">S — настройки · F — полный экран · → — следующий экран · пробел — пауза</p>
|
||||
</div>
|
||||
<div className="settings-actions"><button type="button" className="text-button" onClick={() => setDraft({ ...draft, mainSeconds: 25, infoSeconds: 8, rowCount: 4, holdNear: true, adsEnabled: false })}><Reset size={17} /> Сбросить цикл</button><button type="submit" className="primary-button"><Check size={18} /> Сохранить</button></div>
|
||||
</form>
|
||||
</dialog>;
|
||||
}
|
||||
30
src/WeatherBlock.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Cloud, Moon, Rain, Snow, Storm, Sun, Wind } from './Icons';
|
||||
import { NumericText } from './NumericText';
|
||||
import { weatherAvailable, weatherCondition } from './lib/weather.mjs';
|
||||
import type { Weather } from './types';
|
||||
|
||||
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' });
|
||||
const degrees = (value: number) => `${Math.round(value) > 0 ? '+' : ''}${Math.round(value)}°`;
|
||||
function WeatherIcon({ code, isDay = true, size = 32 }: { code: number; isDay?: boolean; size?: number }) {
|
||||
const condition = weatherCondition(code, isDay);
|
||||
const Icon = icons[condition.icon as keyof typeof icons];
|
||||
return <Icon size={size} />;
|
||||
}
|
||||
export function WeatherSummary({ data, now, failed }: { data: Weather | null; now: number; failed: boolean }) {
|
||||
if (!weatherAvailable(data, now) || !data) return null;
|
||||
return <div className="weather-summary" aria-label={`Погода: ${degrees(data.temperature)}, ${weatherCondition(data.code, data.isDay).label}${failed ? ', сохранённые данные' : ''}`}>
|
||||
<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 }) {
|
||||
const available = weatherAvailable(data, now);
|
||||
if (!available || !data) return <><div className="card-icon"><Cloud /></div><h3>Погода</h3><p>{demo ? 'Погода доступна с реальными данными остановки.' : loading ? 'Получаем прогноз…' : 'Прогноз сейчас недоступен.'}</p><div className="info-caption">{data ? 'Сохранённый прогноз устарел' : 'Погода обновится при подключении'}</div></>;
|
||||
const hours = data.hourly.filter(hour => Date.parse(hour.at) > now).slice(0, 3);
|
||||
const probability = hours.length ? Math.max(...hours.map(hour => hour.precipitationProbability)) : null;
|
||||
return <><h3>Погода</h3><div className="weather-current"><WeatherIcon code={data.code} isDay={data.isDay} size={60} /><strong><NumericText value={degrees(data.temperature)} /></strong></div>
|
||||
<p className="weather-description">{weatherCondition(data.code, data.isDay).label}</p><div className="weather-details"><span>Ощущается как <NumericText value={degrees(data.apparentTemperature)} /></span><span><Wind size={20} /><NumericText value={Math.round(data.windSpeed)} /> м/с</span></div>
|
||||
{!!hours.length && <div className="weather-hours">{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={24} /><strong><NumericText value={degrees(hour.temperature)} /></strong></div>)}</div>}
|
||||
{probability !== null && <div className="precipitation-chance">Осадки в ближайшие 3 часа: <NumericText value={probability} />%</div>}
|
||||
<div className="weather-attribution"><span>{failed ? 'Сохранено' : 'Обновлено'} в {hourFormat.format(Date.parse(data.updatedAt))}</span><a href="https://open-meteo.com/" target="_blank" rel="noreferrer">Open-Meteo · CC BY 4.0</a></div></>;
|
||||
}
|
||||
37
src/assets/ridebus-logo.svg
Normal file
@@ -0,0 +1,37 @@
|
||||
|
||||
|
||||
<svg version="1.1" id="Слой_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 59 58" style="enable-background:new 0 0 59 58;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{clip-path:url(#SVGID_2_);}
|
||||
.st1{fill:var(--accent, #d5ff70);}
|
||||
.st2{display:none;fill:none;stroke:#FFFFFF;stroke-width:1.9625;}
|
||||
.st3{fill:#000000;}
|
||||
</style>
|
||||
<g>
|
||||
<defs>
|
||||
<rect id="SVGID_1_" x="0.5" width="58" height="58"/>
|
||||
</defs>
|
||||
<clipPath id="SVGID_2_">
|
||||
<use xlink:href="#SVGID_1_" style="overflow:visible;"/>
|
||||
</clipPath>
|
||||
<g class="st0">
|
||||
<path class="st1" d="M21.9,1.3l19.7,1.6C51,3.6,58,11.8,57.2,21.2l-1.6,19.7c-0.7,9.4-8.9,16.3-18.3,15.6l-19.7-1.6
|
||||
C8.4,54.2,1.4,46,2.1,36.6l1.6-19.7C4.4,7.6,12.6,0.6,21.9,1.3z"/>
|
||||
<path class="st2" d="M23.6,38.6C0.3,23.3,37,15.1,40.3,16 M23.6,38.6C46.1,53.4,41.7,19.3,40.3,16 M23.6,38.6
|
||||
c10.2,3,15.1-17,16.7-22.6"/>
|
||||
</g>
|
||||
</g>
|
||||
<path id="path" class="st3" d="M20.3,44.4c-1.6,0-2.2-0.5-2.3-1.7c0-0.8,0-1.6,0-2.4c0-0.2-0.1-0.3-0.2-0.5c-1-1.2-1.6-2.7-1.5-4.2
|
||||
c0.1-5.1,0-10.1,0-15.2c0-0.8,0.1-1.6,0.3-2.3c0.6-1.9,2-3,3.8-3.5c2.1-0.7,4.3-1,6.5-0.9c2.7,0,5.5,0,8.2,0.2
|
||||
c2.1,0.2,4.2,0.7,5.8,2.3c0.3,0.2,0.5,0.5,0.6,0.8c0.8,1.3,1.2,3,1,4.5c0,2.3,0,4.6-0.1,6.8l0,0c0,2.6,0,5.1,0,7.8
|
||||
c0,1.3-0.5,2.7-1.4,3.7c-0.3,0.3-0.3,0.6-0.3,0.9c0,0.8,0,1.5,0,2.1c0.1,0.6-0.3,1.2-0.8,1.5c-0.2,0.2-0.5,0.3-0.8,0.3
|
||||
c-0.6,0-1.2,0-1.9,0c-0.9,0-1.6-0.7-1.7-1.6c0-0.4,0-0.8,0-1.2c0-0.2-0.2-0.3-0.3-0.4l0,0H23.3c-0.3,0-0.4,0.1-0.3,0.4
|
||||
c0,0.3,0,0.8,0,1.1c0,0.9-0.8,1.7-1.6,1.7 M29.4,20.2L29.4,20.2c-3.1,0-6.2,0-9.2,0c-0.3,0-0.5,0.1-0.5,0.5c0,2.3,0,4.6,0,7
|
||||
c0,0.3,0.1,0.5,0.5,0.5c6.2,0,12.2,0,18.4,0c0.4,0,0.5-0.1,0.5-0.5c0-2.3,0-4.6,0-6.8c0-0.3-0.3-0.6-0.6-0.6l0,0
|
||||
C35.5,20.2,32.5,20.2,29.4,20.2z M37,37.9c1.3-0.2,2.3-1.3,2.1-2.6c-0.2-1.3-1.3-2.3-2.6-2.1c-0.2,0-0.3,0.1-0.5,0.1
|
||||
c-1.3,0.4-1.9,1.8-1.5,3C34.9,37.3,35.9,38,37,37.9L37,37.9z M21.7,37.9c1.3,0.2,2.5-0.6,2.7-1.9c0.2-1.3-0.6-2.5-1.9-2.7
|
||||
c-1.2-0.2-2.5,0.6-2.7,1.9c-0.1,0.2-0.1,0.3-0.1,0.5C19.8,36.8,20.7,37.7,21.7,37.9L21.7,37.9z M26.6,36.2c-0.1,1,0.5,2,1.4,2.4
|
||||
c0.9,0.5,2.1,0.3,3-0.2c0.8-0.5,1.2-1.3,1.1-2.2h-1.2c-0.3,1-0.8,1.5-1.6,1.5c-0.8,0-1.3-0.4-1.6-1.5C27.8,36.2,26.6,36.2,26.6,36.2
|
||||
z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
1
src/env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
111
src/hooks.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { BoardData, Config, Settings, Timetable, Weather } from './types';
|
||||
import { createDemo } from './lib/demo';
|
||||
import { isBoardData } from './lib/board.mjs';
|
||||
import { readSnapshot, writeSnapshot, readTimetable, writeTimetable, readWeather, writeWeather } from './lib/storage';
|
||||
import { isTimetable } from './lib/timetable.mjs';
|
||||
import { isWeather } from './lib/weather.mjs';
|
||||
|
||||
export const fallbackConfig: Config = { cityId: 1, cityName: 'Полоцк', stopName: 'Швейная фабрика', defaultStopId: 1, configured: false, refreshSeconds: 20, timeZone: 'Europe/Minsk', passengerBaseUrl: 'https://pwa.ridebus.by/' };
|
||||
|
||||
// Separate polling and persistence keep weather and bulk schedules out of the live refresh path.
|
||||
function useSavedResource<T>(kind: string, settings: Settings, cityId: number, refreshMs: number, timeoutMs: number,
|
||||
read: (cityId: number, stopId: number) => Promise<T | null>, write: (value: T) => Promise<void>, valid: (value: unknown, cityId: number, stopId: number) => boolean) {
|
||||
const [data, setData] = useState<T | null>(null), [saved, setSaved] = useState<boolean | null>(null), [failed, setFailed] = useState(false), [loading, setLoading] = useState(true);
|
||||
useEffect(() => {
|
||||
let stopped = false, received = false, running = false;
|
||||
let timer: ReturnType<typeof setTimeout>, controller: AbortController | undefined;
|
||||
setData(null); setSaved(null); setFailed(false); setLoading(true);
|
||||
if (settings.demo) { setLoading(false); return; }
|
||||
void read(cityId, settings.stopId).then(value => { if (!stopped && !received && value) { setData(value); setSaved(true); } });
|
||||
async function load() {
|
||||
if (running || stopped) return;
|
||||
running = true; controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller?.abort(), timeoutMs);
|
||||
let succeeded = false;
|
||||
try {
|
||||
const response = await fetch(`/api/${kind}?stopId=${settings.stopId}`, { signal: controller.signal, cache: 'no-store' });
|
||||
if (!response.ok) throw new Error();
|
||||
const value = await response.json();
|
||||
if (!valid(value, cityId, settings.stopId)) throw new Error();
|
||||
if (!stopped) {
|
||||
received = true; succeeded = true; setData(value); setFailed(false);
|
||||
void write(value).then(() => { if (!stopped) setSaved(true); }).catch(() => { if (!stopped) setSaved(false); });
|
||||
}
|
||||
} catch { if (!stopped) setFailed(true); }
|
||||
finally { clearTimeout(timeout); running = false; if (!stopped) { setLoading(false); timer = setTimeout(load, succeeded ? refreshMs : 60000); } }
|
||||
}
|
||||
function reconnect() { clearTimeout(timer); void load(); }
|
||||
void load(); window.addEventListener('online', reconnect);
|
||||
return () => { stopped = true; clearTimeout(timer); controller?.abort(); window.removeEventListener('online', reconnect); };
|
||||
}, [kind, settings.demo, settings.stopId, cityId, refreshMs, timeoutMs, read, write, valid]);
|
||||
return { data, saved, failed, loading };
|
||||
}
|
||||
export const useTimetable = (settings: Settings, config: Config) => useSavedResource<Timetable>('timetable', settings, config.cityId, 1800000, 90000, readTimetable, writeTimetable, isTimetable);
|
||||
export const useWeather = (settings: Settings, config: Config) => useSavedResource<Weather>('weather', settings, config.cityId, 600000, 14000, readWeather, writeWeather, isWeather);
|
||||
|
||||
export function useBoard(settings: Settings, config: Config) {
|
||||
const [data, setData] = useState<BoardData | null>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [refreshId, setRefreshId] = useState(0);
|
||||
const [error, setError] = useState('');
|
||||
const [offlineSaved, setOfflineSaved] = useState<boolean | null>(null);
|
||||
useEffect(() => {
|
||||
let stopped = false, received = false, running = false;
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
let controller: AbortController | null = null;
|
||||
setData(null); setFailed(false); setLoading(true); setError(''); setOffset(0); setOfflineSaved(null);
|
||||
if (settings.demo) {
|
||||
setData(createDemo()); setLoading(false);
|
||||
const demoTimer = setInterval(() => setData(createDemo()), 60000);
|
||||
return () => clearInterval(demoTimer);
|
||||
}
|
||||
readSnapshot(config.cityId, settings.stopId).then(cached => {
|
||||
if (!stopped && !received && cached) { setData(cached); setFailed(true); setLoading(false); setOfflineSaved(true); }
|
||||
}).catch(() => { /* Offline storage may be unavailable in private mode. */ });
|
||||
async function load() {
|
||||
if (running || stopped) return;
|
||||
running = true;
|
||||
controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller?.abort(), 14000);
|
||||
const started = Date.now();
|
||||
try {
|
||||
const response = await fetch(`/api/board?stopId=${settings.stopId}`, { signal: controller.signal, cache: 'no-store' });
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => null);
|
||||
throw new Error(body?.error === 'api_not_configured' ? 'api_not_configured' : 'data_unavailable');
|
||||
}
|
||||
const result: BoardData = await response.json();
|
||||
if (!isBoardData(result, config.cityId, settings.stopId)) throw new Error('data_unavailable');
|
||||
if (!stopped) {
|
||||
received = true; setData(result); setFailed(false); setError('');
|
||||
const serverTime = Date.parse(result.serverTime || '');
|
||||
if (Number.isFinite(serverTime)) setOffset(serverTime - (started + Date.now()) / 2);
|
||||
writeSnapshot(result).then(() => { if (!stopped) setOfflineSaved(true); }).catch(() => { if (!stopped) setOfflineSaved(false); });
|
||||
}
|
||||
} catch (error) {
|
||||
if (!stopped) { setFailed(true); setError(error instanceof Error ? error.message : 'data_unavailable'); }
|
||||
} finally {
|
||||
clearTimeout(timeout); running = false;
|
||||
if (!stopped) { setLoading(false); timer = setTimeout(load, config.refreshSeconds * 1000); }
|
||||
}
|
||||
}
|
||||
function reconnect() { clearTimeout(timer); void load(); }
|
||||
void load();
|
||||
window.addEventListener('online', reconnect);
|
||||
return () => { stopped = true; clearTimeout(timer); controller?.abort(); window.removeEventListener('online', reconnect); };
|
||||
}, [settings.demo, settings.stopId, config.cityId, config.refreshSeconds, refreshId]);
|
||||
return { data, failed, loading, offset, error, offlineSaved, refresh: () => setRefreshId(value => value + 1) };
|
||||
}
|
||||
|
||||
export function useClock(offset: number) {
|
||||
const [now, setNow] = useState(Date.now() + offset);
|
||||
useEffect(() => {
|
||||
setNow(Date.now() + offset);
|
||||
const timer = setInterval(() => setNow(Date.now() + offset), 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, [offset]);
|
||||
return now;
|
||||
}
|
||||
24
src/lib/board.mjs
Normal file
@@ -0,0 +1,24 @@
|
||||
export const FRESH_MS = 90000;
|
||||
export const SNAPSHOT_MAX_MS = 24 * 60 * 60 * 1000;
|
||||
export const minskDate = now => new Intl.DateTimeFormat('sv-SE', { timeZone: 'Europe/Minsk', year: 'numeric', month: '2-digit', day: '2-digit' }).format(now);
|
||||
|
||||
export function upcomingDepartures(data, now, routeFilter = '') {
|
||||
if (!data || now - Date.parse(data.updatedAt) > SNAPSHOT_MAX_MS || data.serviceDate !== minskDate(now)) return [];
|
||||
return data.departures.filter(item => Number.isFinite(Date.parse(item.scheduledAt)) && Date.parse(item.scheduledAt) + 60000 > now && (!routeFilter || String(item.routeId) === routeFilter));
|
||||
}
|
||||
export function activeAlerts(alerts, now, routeFilter = '', routes = []) {
|
||||
const selectedNumber = routes.find(route => String(route.id) === routeFilter)?.number;
|
||||
return alerts.filter(alert => (!alert.startAt || Date.parse(alert.startAt) <= now) && (!alert.endAt || Date.parse(alert.endAt) > now)
|
||||
&& (!routeFilter || !alert.routeIds.length && !alert.routeNumbers.length || alert.routeIds.includes(Number(routeFilter)) || alert.routeNumbers.includes(selectedNumber)));
|
||||
}
|
||||
export function minutesUntil(item, now) { return Math.max(0, Math.ceil((Date.parse(item.scheduledAt) - now) / 60000)); }
|
||||
export function shouldHoldScene(settings, departure, now) {
|
||||
return Boolean(settings.holdNear && departure && minutesUntil(departure, now) <= 2);
|
||||
}
|
||||
export function isFresh(data, now, connectionFailed) { return !connectionFailed && Boolean(data) && now - Date.parse(data.updatedAt) <= FRESH_MS; }
|
||||
export function isBoardData(value, cityId, stopId) {
|
||||
return Boolean(value && value.cityId === cityId && value.stop?.id === stopId && value.mode === 'live' && Array.isArray(value.departures)
|
||||
&& Array.isArray(value.routes) && Array.isArray(value.alerts) && typeof value.serviceDate === 'string' && Number.isFinite(Date.parse(value.updatedAt))
|
||||
&& value.departures.every(item => typeof item.id === 'string' && Number.isFinite(Date.parse(item.scheduledAt)))
|
||||
&& value.alerts.every(alert => typeof alert.id === 'string' && Array.isArray(alert.routeIds) && Array.isArray(alert.routeNumbers)));
|
||||
}
|
||||
21
src/lib/demo.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { BoardData } from '../types';
|
||||
import { minskDate } from './board.mjs';
|
||||
|
||||
export function createDemo(now = Date.now()): BoardData {
|
||||
const routes = [
|
||||
{ id: 42, number: '16тк', name: 'Боровуха-3 — Больница (Новка)', destination: 'Больница (Новка)', type: 'minibus' },
|
||||
{ id: 1, number: '1', name: 'Швейная фабрика — Богатырская', destination: 'Богатырская', type: 'bus' },
|
||||
{ id: 12, number: '6', name: 'Швейная фабрика — Калинина', destination: 'Калинина', type: 'bus' },
|
||||
{ id: 22, number: '11', name: 'Швейная фабрика — Тросницкая', destination: 'Тросницкая', type: 'bus' },
|
||||
{ id: 21, number: '2тк', name: 'Больница (Ксты) — Больница (Новка)', destination: 'Больница (Новка)', type: 'minibus' },
|
||||
];
|
||||
return { cityId: 1, cityName: 'Полоцк', stop: { id: 1, name: 'Швейная фабрика', direction: 'основная' },
|
||||
routes, departures: [4, 8, 12, 17, 23, 31, 38].map((minutes, index) => {
|
||||
const route = routes[index % routes.length];
|
||||
const time = new Date(Math.floor(now / 60000) * 60000 + minutes * 60000);
|
||||
return { id: `demo-${index}`, routeId: route.id, routeNumber: route.number, routeName: route.name,
|
||||
destination: route.destination, type: route.type, scheduledAt: time.toISOString(),
|
||||
scheduledTime: new Intl.DateTimeFormat('ru', { timeZone: 'Europe/Minsk', hour: '2-digit', minute: '2-digit' }).format(time),
|
||||
note: '', source: 'schedule' };
|
||||
}), alerts: [], alertsAvailable: true, partial: false, updatedAt: new Date(now).toISOString(), serviceDate: minskDate(now), mode: 'demo' };
|
||||
}
|
||||
8
src/lib/passenger.mjs
Normal file
@@ -0,0 +1,8 @@
|
||||
export function passengerLink(baseUrl, cityId, stopId) {
|
||||
let url;
|
||||
try { url = new URL(baseUrl || 'https://pwa.ridebus.by/'); if (!['https:', 'http:'].includes(url.protocol)) throw new Error(); }
|
||||
catch { url = new URL('https://pwa.ridebus.by/'); }
|
||||
url.username = ''; url.password = ''; url.hash = '';
|
||||
url.search = new URLSearchParams({ city: String(cityId), stop: String(stopId) }).toString();
|
||||
return url.toString();
|
||||
}
|
||||
33
src/lib/presentation.mjs
Normal file
@@ -0,0 +1,33 @@
|
||||
export const colorSchemes = [
|
||||
{ id: 'lime', name: 'Лайм', color: '#d5ff70' },
|
||||
{ id: 'amber', name: 'Янтарь', color: '#ffd479' },
|
||||
{ id: 'cyan', name: 'Голубой', color: '#79e7ff' },
|
||||
{ id: 'mint', name: 'Мята', color: '#91ffd0' },
|
||||
{ id: 'lavender', name: 'Лаванда', color: '#d3bdff' },
|
||||
{ id: 'white', name: 'Белый', color: '#ffffff' },
|
||||
];
|
||||
export function colorScheme(id) { return colorSchemes.find(scheme => scheme.id === id) || colorSchemes[0]; }
|
||||
|
||||
export function numericFrame(previous, current, previousNumber, currentNumber) {
|
||||
const width = Math.max(previous.length, current.length);
|
||||
const before = previous.padStart(width, ' '), after = current.padStart(width, ' ');
|
||||
return { direction: currentNumber >= previousNumber ? 'up' : 'down', glyphs: Array.from(after, (character, index) => ({
|
||||
character, previous: before[index], position: width - index - 1,
|
||||
changed: character !== before[index] && (/\d/.test(character) || /\d/.test(before[index])),
|
||||
})) };
|
||||
}
|
||||
|
||||
export function departurePage(departures, requested, capacity, page) {
|
||||
const count = Math.min(requested, departures.length);
|
||||
const pages = Math.max(1, Math.ceil(count / Math.max(1, capacity)));
|
||||
const size = Math.max(1, Math.ceil(count / pages));
|
||||
const start = (page % pages) * size;
|
||||
return { items: departures.slice(start, Math.min(start + size, count)), start, count, pages, size };
|
||||
}
|
||||
|
||||
export function stopDirection(stop) {
|
||||
const direction = stop?.direction?.trim();
|
||||
// "основная" is a platform classification, not a travel direction.
|
||||
if (!direction || direction.toLowerCase() === 'основная') return '';
|
||||
return direction.charAt(0).toUpperCase() + direction.slice(1);
|
||||
}
|
||||
78
src/lib/storage.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import type { BoardData, Settings } from '../types';
|
||||
import { isBoardData } from './board.mjs';
|
||||
import { colorScheme } from './presentation.mjs';
|
||||
import { isTimetable } from './timetable.mjs';
|
||||
import { isWeather } from './weather.mjs';
|
||||
import type { Timetable, Weather } 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: '', colorScheme: 'lime', adsEnabled: false, adSeconds: 7, adTitle: 'Ваше место в городе', adText: 'Короткая история. Большая аудитория.' };
|
||||
export function readSettings(): Settings {
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem(SETTINGS_KEY) || '{}');
|
||||
const bound = (value: unknown, fallback: number, min: number, max: number) => typeof value === 'number' && Number.isFinite(value) ? Math.min(max, Math.max(min, Math.round(value))) : fallback;
|
||||
return { ...defaultSettings, stopId: bound(saved.stopId, 1, 1, 1000000), demo: saved.demo === true,
|
||||
mainSeconds: bound(saved.mainSeconds, 25, 5, 120), infoSeconds: bound(saved.infoSeconds, 8, 5, 60), rowCount: bound(saved.rowCount, 4, 2, 6),
|
||||
holdNear: saved.holdNear !== false, routeFilter: typeof saved.routeFilter === 'string' && /^\d*$/.test(saved.routeFilter) ? saved.routeFilter : '',
|
||||
colorScheme: colorScheme(saved.colorScheme).id, adsEnabled: saved.adsEnabled === true, adSeconds: bound(saved.adSeconds, 7, 5, 30),
|
||||
adTitle: typeof saved.adTitle === 'string' ? saved.adTitle.slice(0, 80) : defaultSettings.adTitle,
|
||||
adText: typeof saved.adText === 'string' ? saved.adText.slice(0, 200) : defaultSettings.adText };
|
||||
} catch { return defaultSettings; }
|
||||
}
|
||||
export function saveSettings(settings: Settings) { try { localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings)); } catch { /* Settings still apply for this session. */ } }
|
||||
export function hasSavedSettings() { try { return localStorage.getItem(SETTINGS_KEY) !== null; } catch { return false; } }
|
||||
|
||||
function database(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open('ridebus-board-v1', 1);
|
||||
let timedOut = false;
|
||||
const timeout = setTimeout(() => { timedOut = true; reject(new Error('Offline database did not respond')); }, 2500);
|
||||
request.onupgradeneeded = () => request.result.createObjectStore('snapshots');
|
||||
request.onsuccess = () => { clearTimeout(timeout); if (timedOut) request.result.close(); else resolve(request.result); };
|
||||
request.onerror = () => { clearTimeout(timeout); reject(request.error); };
|
||||
});
|
||||
}
|
||||
async function writeResource(key: string, data: unknown) {
|
||||
let backup = false;
|
||||
try { localStorage.setItem(key, JSON.stringify(data)); backup = true; } catch { /* Try IndexedDB independently. */ }
|
||||
let db: IDBDatabase | undefined;
|
||||
try {
|
||||
db = await database();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const transaction = db!.transaction('snapshots', 'readwrite');
|
||||
transaction.objectStore('snapshots').put(data, key);
|
||||
transaction.oncomplete = () => resolve(); transaction.onerror = () => reject(transaction.error);
|
||||
transaction.onabort = () => reject(transaction.error);
|
||||
});
|
||||
} catch (error) { if (!backup) throw error; }
|
||||
finally { db?.close(); }
|
||||
}
|
||||
async function readResource<T>(key: string, valid: (value: unknown) => boolean, legacyKey = key): Promise<T | null> {
|
||||
// Some kiosk WebViews make IndexedDB unavailable or suspend its requests.
|
||||
// A small, public timetable snapshot also lives in localStorage.
|
||||
try {
|
||||
const cached = JSON.parse(localStorage.getItem(key) || 'null');
|
||||
if (valid(cached)) return cached;
|
||||
} catch { /* Try IndexedDB independently. */ }
|
||||
let db: IDBDatabase | undefined;
|
||||
try {
|
||||
db = await database();
|
||||
return await new Promise((resolve, reject) => {
|
||||
const request = db!.transaction('snapshots').objectStore('snapshots').get(legacyKey);
|
||||
request.onsuccess = () => resolve(valid(request.result) ? request.result : null);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
} catch { return null; }
|
||||
finally { db?.close(); }
|
||||
}
|
||||
export const writeSnapshot = (data: BoardData) => writeResource(`ridebus-board-snapshot:${data.cityId}:${data.stop.id}`, data);
|
||||
export const readSnapshot = async (cityId: number, stopId: number) => {
|
||||
const key = `ridebus-board-snapshot:${cityId}:${stopId}`;
|
||||
const valid = (value: unknown) => isBoardData(value, cityId, stopId);
|
||||
return await readResource<BoardData>(key, valid) || await readResource<BoardData>(key, valid, `${cityId}:${stopId}`);
|
||||
};
|
||||
export const writeTimetable = (data: Timetable) => writeResource(`ridebus-board-timetable:${data.cityId}:${data.stop.id}`, data);
|
||||
export const readTimetable = (cityId: number, stopId: number) => readResource<Timetable>(`ridebus-board-timetable:${cityId}:${stopId}`, value => isTimetable(value, cityId, stopId));
|
||||
export const writeWeather = (data: Weather) => writeResource(`ridebus-board-weather:${data.cityId}:${data.stopId}`, data);
|
||||
export const readWeather = (cityId: number, stopId: number) => readResource<Weather>(`ridebus-board-weather:${cityId}:${stopId}`, value => isWeather(value, cityId, stopId));
|
||||
50
src/lib/timetable.mjs
Normal file
@@ -0,0 +1,50 @@
|
||||
export const TIMETABLE_MAX_MS = 7 * 86400000;
|
||||
export const localDate = now => new Intl.DateTimeFormat('sv-SE', { timeZone: 'Europe/Minsk', year: 'numeric', month: '2-digit', day: '2-digit' }).format(now);
|
||||
export const shiftDate = (date, days) => new Date(Date.parse(`${date}T12:00:00Z`) + days * 86400000).toISOString().slice(0, 10);
|
||||
export function validDate(value) {
|
||||
return typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value) && Number.isFinite(Date.parse(`${value}T12:00:00Z`))
|
||||
&& new Date(`${value}T12:00:00Z`).toISOString().slice(0, 10) === value;
|
||||
}
|
||||
export function isTimetable(value, cityId, stopId) {
|
||||
return Boolean(value?.version === 1 && value.cityId === cityId && value.stop?.id === stopId && typeof value.stop.name === 'string'
|
||||
&& Number.isFinite(Date.parse(value.updatedAt)) && Number.isFinite(Date.parse(value.expiresAt))
|
||||
&& Array.isArray(value.calendarYears) && value.calendarYears.every(Number.isInteger)
|
||||
&& Array.isArray(value.holidays) && value.holidays.every(validDate)
|
||||
&& Array.isArray(value.routes) && value.routes.every(route => Number.isSafeInteger(route.id) && route.id > 0 && typeof route.number === 'string'
|
||||
&& typeof route.name === 'string' && Array.isArray(route.schedules) && route.schedules.every(schedule =>
|
||||
['weekday', 'saturday', 'sunday', 'holiday'].includes(schedule.dayType) && (!schedule.validFrom || validDate(schedule.validFrom))
|
||||
&& (!schedule.validUntil || validDate(schedule.validUntil)) && Array.isArray(schedule.trips) && schedule.trips.every(trip =>
|
||||
Number.isFinite(trip.arrivalSeconds) && trip.arrivalSeconds >= 0 && trip.arrivalSeconds < 172800
|
||||
&& typeof trip.destination === 'string' && typeof trip.note === 'string' && Array.isArray(trip.daysOfWeek)
|
||||
&& Array.isArray(trip.exceptDaysOfWeek) && [...trip.daysOfWeek, ...trip.exceptDaysOfWeek].every(day => Number.isInteger(day) && day >= 1 && day <= 7)))));
|
||||
}
|
||||
export function timetableAvailable(bundle, now) {
|
||||
return Boolean(bundle && now >= Date.parse(bundle.updatedAt) - 60000 && now < Date.parse(bundle.expiresAt)
|
||||
&& now - Date.parse(bundle.updatedAt) <= TIMETABLE_MAX_MS && bundle.calendarYears.includes(Number(localDate(now).slice(0, 4))));
|
||||
}
|
||||
// Expand civil service dates, retaining the next date for arrivals past midnight.
|
||||
export function timetableDepartures(bundle, date) {
|
||||
if (!bundle) return [];
|
||||
const result = [];
|
||||
const holidays = new Set(bundle.holidays);
|
||||
for (let day = -1; day <= 1; day++) {
|
||||
const serviceDate = shiftDate(date, day);
|
||||
if (!bundle.calendarYears.includes(Number(serviceDate.slice(0, 4)))) continue;
|
||||
const weekday = new Date(`${serviceDate}T12:00:00Z`).getUTCDay() || 7;
|
||||
const dayType = holidays.has(serviceDate) ? 'holiday' : weekday === 7 ? 'sunday' : weekday === 6 ? 'saturday' : 'weekday';
|
||||
for (const route of bundle.routes) {
|
||||
const eligible = route.schedules.filter(schedule => (!schedule.validFrom || schedule.validFrom <= serviceDate) && (!schedule.validUntil || schedule.validUntil >= serviceDate));
|
||||
const schedule = eligible.find(schedule => schedule.dayType === dayType) || (dayType === 'holiday' ? eligible.find(schedule => schedule.dayType === 'sunday') : null);
|
||||
for (const [index, trip] of (schedule?.trips || []).entries()) {
|
||||
if (trip.daysOfWeek.length && !trip.daysOfWeek.includes(weekday) || trip.exceptDaysOfWeek.includes(weekday)) continue;
|
||||
const scheduledAt = new Date(Date.parse(`${serviceDate}T00:00:00+03:00`) + trip.arrivalSeconds * 1000).toISOString();
|
||||
const arrivalDate = localDate(Date.parse(scheduledAt));
|
||||
if (arrivalDate < date || arrivalDate > shiftDate(date, 1)) continue;
|
||||
result.push({ id: `${route.id}:${serviceDate}:${index}:${scheduledAt}`, routeId: route.id, routeNumber: route.number,
|
||||
routeName: route.name, destination: trip.destination, type: route.type, scheduledAt,
|
||||
scheduledTime: new Date(Date.parse(scheduledAt) + 10800000).toISOString().slice(11, 16), note: trip.note, source: 'schedule' });
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.sort((a, b) => Date.parse(a.scheduledAt) - Date.parse(b.scheduledAt) || a.routeNumber.localeCompare(b.routeNumber, 'ru', { numeric: true }));
|
||||
}
|
||||
23
src/lib/weather.mjs
Normal file
@@ -0,0 +1,23 @@
|
||||
export const WEATHER_MAX_MS = 60 * 60000;
|
||||
export function isWeather(value, cityId, stopId) {
|
||||
return Boolean(value?.cityId === cityId && value.stopId === stopId && Number.isFinite(Date.parse(value.updatedAt)) && Number.isFinite(Date.parse(value.observedAt))
|
||||
&& Number.isFinite(value.temperature) && Number.isFinite(value.apparentTemperature) && Number.isInteger(value.code) && typeof value.isDay === 'boolean'
|
||||
&& Number.isFinite(value.windSpeed) && value.windSpeed >= 0 && Array.isArray(value.hourly) && value.hourly.every(hour =>
|
||||
Number.isFinite(Date.parse(hour.at)) && Number.isFinite(hour.temperature) && Number.isInteger(hour.code)
|
||||
&& Number.isFinite(hour.precipitationProbability) && hour.precipitationProbability >= 0 && hour.precipitationProbability <= 100));
|
||||
}
|
||||
export function weatherAvailable(data, now) {
|
||||
return Boolean(data && now >= Date.parse(data.observedAt) - 900000 && now - Date.parse(data.observedAt) <= WEATHER_MAX_MS
|
||||
&& now - Date.parse(data.updatedAt) <= WEATHER_MAX_MS);
|
||||
}
|
||||
export function weatherCondition(code, isDay = true) {
|
||||
if (code === 0) return { label: isDay ? 'Ясно' : 'Ясная ночь', icon: isDay ? 'sun' : 'moon' };
|
||||
if (code <= 2 && code >= 1) return { label: 'Переменная облачность', icon: 'cloud' };
|
||||
if (code === 3) return { label: 'Пасмурно', icon: 'cloud' };
|
||||
if ([45, 48].includes(code)) return { label: 'Туман', icon: 'cloud' };
|
||||
if ([51, 53, 55, 56, 57].includes(code)) return { label: 'Морось', icon: 'rain' };
|
||||
if ([61, 63, 65, 66, 67, 80, 81, 82].includes(code)) return { label: 'Дождь', icon: 'rain' };
|
||||
if ([71, 73, 75, 77, 85, 86].includes(code)) return { label: 'Снег', icon: 'snow' };
|
||||
if ([95, 96, 99].includes(code)) return { label: 'Гроза', icon: 'storm' };
|
||||
return { label: 'Погода', icon: 'cloud' };
|
||||
}
|
||||
16
src/main.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import '@fontsource-variable/manrope';
|
||||
import App from './App';
|
||||
import './styles.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(<React.StrictMode><App /></React.StrictMode>);
|
||||
|
||||
if ('serviceWorker' in navigator && !import.meta.env.DEV) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/sw.js').then(registration => {
|
||||
// Install updates in the background, activate them on a controlled next reload.
|
||||
setInterval(() => registration.update().catch(() => {}), 60 * 60 * 1000);
|
||||
}).catch(() => {});
|
||||
});
|
||||
}
|
||||
375
src/styles.css
Normal file
@@ -0,0 +1,375 @@
|
||||
:root { font-family: 'Manrope Variable', Arial, sans-serif; color: #f5f5f5; background: #000000; font-synthesis: none; --accent: #d5ff70; --muted: #b7b7b7; --line: #303030; --warning: #ffd479; color-scheme: dark; }
|
||||
* { box-sizing: border-box; }
|
||||
html, body, #root { margin: 0; width: 100%; height: 100%; background: #000000; }
|
||||
button, input, select, textarea { font: inherit; }
|
||||
button { cursor: pointer; color: inherit; }
|
||||
button, a { touch-action: manipulation; -webkit-tap-highlight-color: transparent; }
|
||||
button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; }
|
||||
svg { display: block; flex-shrink: 0; }
|
||||
.vkuiIcon { display: inline-flex; flex-shrink: 0; }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
|
||||
|
||||
/* The main track alone can shrink. The header and footer always own their space. */
|
||||
.board { --hero-height: clamp(100px, 18vh, 230px); height: 100dvh; width: 100%; display: flex; flex-direction: column; overflow: hidden; padding: clamp(14px, 2vh, 28px) clamp(18px, 3vw, 64px) 0; background: #000000; }
|
||||
.board[data-row-count='5'], .board[data-row-count='6'] { --hero-height: clamp(82px, 14vh, 175px); }
|
||||
.board-header { display: flex; align-items: center; justify-content: space-between; gap: 24px; flex: 0 0 auto; border-bottom: 1px solid var(--line); padding-bottom: clamp(12px, 1.8vh, 24px); view-transition-name: board-header; }
|
||||
.stop-heading { min-width: 0; }
|
||||
h1 { font-size: clamp(30px, 3.8vw, 76px); letter-spacing: -.05em; line-height: 1.08; margin: 0; font-weight: 650; overflow-wrap: anywhere; }
|
||||
.stop-direction { color: var(--muted); display: flex; align-items: center; gap: 10px; font-size: clamp(18px, 1.6vw, 32px); margin-top: 10px; line-height: 1.2; }
|
||||
.stop-direction svg { color: var(--accent); }
|
||||
.stop-meta { display: flex; align-items: center; gap: 12px 24px; flex-wrap: wrap; }
|
||||
.weather-summary { display: flex; align-items: center; gap: 9px; margin-top: 10px; color: var(--muted); font-size: clamp(16px, 1.3vw, 26px); line-height: 1.2; }
|
||||
.weather-summary svg, .weather-summary strong { color: var(--accent); }
|
||||
.weather-summary strong { font-size: 1.25em; font-weight: 600; }
|
||||
.weather-summary small { font-size: .7em; }
|
||||
.header-right { display: grid; grid-template-columns: auto auto; column-gap: clamp(20px, 3vw, 64px); align-items: center; flex-shrink: 0; }
|
||||
.brand { grid-row: 1 / 3; display: flex; align-items: center; gap: 12px; font-size: clamp(24px, 2.3vw, 44px); font-weight: 800; letter-spacing: -.07em; white-space: nowrap; }
|
||||
.official-mark { display: block; width: clamp(40px, 3.5vw, 70px); height: auto; flex-shrink: 0; }
|
||||
.official-mark svg { width: 100%; height: auto; }
|
||||
.brand-dot { color: var(--accent); }
|
||||
.clock { display: flex; gap: 10px; align-items: baseline; font-size: clamp(38px, 4.6vw, 90px); font-weight: 550; line-height: 1; font-variant-numeric: tabular-nums; letter-spacing: -.035em; white-space: nowrap; }
|
||||
.clock small { font-size: .42em; color: #bdbdbd; font-weight: 500; }
|
||||
.today { grid-column: 2; color: #bdbdbd; text-align: right; font-size: clamp(13px, 1.15vw, 22px); margin-top: 4px; white-space: nowrap; }
|
||||
.scene-container { flex: 1 1 0; min-height: 0; min-width: 0; overflow: hidden; }
|
||||
.scene { width: 100%; height: 100%; min-height: 0; animation: scene-in 400ms ease-out both; }
|
||||
.departure-layout { display: flex; flex-direction: column; height: 100%; min-height: 0; padding: clamp(10px, 1.6vh, 24px) 0; }
|
||||
.departure-content { flex: 1 1 0; min-height: 0; min-width: 0; display: grid; grid-template-rows: auto var(--hero-height) auto minmax(0, 1fr); }
|
||||
.section-heading, .next-heading { display: flex; align-items: center; justify-content: space-between; gap: 15px; min-width: 0; }
|
||||
.eyebrow { font-size: clamp(12px, 1.05vw, 21px); font-weight: 650; letter-spacing: .12em; color: var(--muted); }
|
||||
.source-label { display: flex; align-items: center; gap: 8px; color: #c3c3c3; font-size: clamp(13px, 1.15vw, 23px); white-space: nowrap; }
|
||||
.source-label svg { width: 1.2em; height: 1.2em; }
|
||||
.hero-departure { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; grid-template-rows: minmax(0, 1fr); align-items: center; gap: clamp(18px, 2.4vw, 50px); min-height: 0; padding: 6px 0; }
|
||||
.route-badge { display: inline-flex; justify-content: center; align-items: center; flex-shrink: 0; border: 1.5px solid #595959; border-radius: 12px; width: clamp(64px, 8vw, 150px); height: min(82px, calc(var(--row-height, 96px) * .76)); padding: 0 clamp(10px, 1vw, 18px); font-size: min(50px, calc(var(--row-height, 96px) * .48)); font-weight: 650; line-height: 1.12; font-variant-numeric: tabular-nums; letter-spacing: -.025em; overflow: hidden; }
|
||||
.route-badge-label { display: inline-block; flex-shrink: 0; white-space: nowrap; transform-origin: center; }
|
||||
.route-badge-large { width: calc(var(--hero-height) * 1.05); height: calc(var(--hero-height) - 12px); border: none; background: var(--accent); color: #000; border-radius: clamp(14px, 1.5vw, 28px); padding: 0 clamp(16px, 1.4vw, 28px); font-size: min(130px, calc(var(--hero-height) * .68)); }
|
||||
.hero-destination { min-width: 0; }
|
||||
.transport-kind { display: flex; align-items: center; gap: 9px; color: var(--accent); font-size: min(30px, calc(var(--hero-height) * .17)); line-height: 1.2; margin-bottom: 5px; }
|
||||
.hero-destination h2 { font-size: min(5vw, calc(var(--hero-height) * .34)); line-height: 1.08; letter-spacing: -.035em; font-weight: 650; margin: 0; overflow-wrap: anywhere; }
|
||||
.hero-details { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 13px; font-size: min(27px, calc(var(--hero-height) * .12)); color: #bdbdbd; margin-top: 4px; line-height: 1.25; }
|
||||
.schedule-time { border: 1px solid #505050; border-radius: 6px; padding: 1px 6px; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
.countdown { display: flex; align-items: baseline; justify-content: flex-end; gap: 9px; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
.countdown > span { font-size: min(68px, calc(var(--row-height, 92px) * .64)); font-weight: 550; line-height: 1.1; letter-spacing: -.035em; }
|
||||
.countdown small { color: #c3c3c3; font-size: min(27px, calc(var(--row-height, 92px) * .29)); }
|
||||
.countdown-large > span { color: var(--accent); font-size: min(154px, calc(var(--hero-height) * .86)); }
|
||||
.countdown-large small { font-size: clamp(19px, 2vw, 38px); }
|
||||
.countdown .countdown-now { font-size: min(40px, calc(var(--row-height, 92px) * .4)); }
|
||||
.countdown-large .countdown-now { font-size: min(72px, calc(var(--hero-height) * .44)); }
|
||||
.countdown-future { flex-direction: column; align-items: flex-end; gap: 1px; }
|
||||
.countdown-future > span { font-size: min(40px, calc(var(--row-height, 92px) * .4)); }
|
||||
.countdown-large.countdown-future > span { font-size: min(70px, calc(var(--hero-height) * .45)); }
|
||||
.next-heading { border-top: 1px solid var(--line); padding: clamp(8px, 1.2vh, 16px) 0; }
|
||||
.next-heading svg { color: #bdbdbd; }
|
||||
.page-indicator { color: var(--accent); font-size: 15px; }
|
||||
.departure-table { min-height: 0; display: grid; grid-template-rows: repeat(var(--rows), minmax(0, 1fr)); overflow: hidden; }
|
||||
.departure-row { display: grid; grid-template-columns: auto minmax(0, 1fr) auto minmax(105px, .17fr); align-items: center; gap: clamp(15px, 2.4vw, 48px); min-height: 0; border-bottom: 1px solid #303030; }
|
||||
.departure-row:last-child { border-bottom: 0; }
|
||||
.row-destination { min-width: 0; min-height: 0; height: 100%; padding: 3px 0; display: flex; flex-direction: column; justify-content: center; }
|
||||
.row-destination h3 { flex-shrink: 0; font-size: min(54px, 3.5vw, calc(var(--row-height) * .43)); font-weight: 600; line-height: 1.1; letter-spacing: -.025em; margin: 0; overflow-wrap: anywhere; }
|
||||
.row-destination p { margin: 5px 0 0; font-size: min(24px, 1.35vw, calc(var(--row-height) * .2)); line-height: 1.2; color: #bcbcbc; }
|
||||
.departure-table[data-rows='5'] .row-destination p, .departure-table[data-rows='6'] .row-destination p { display: none; }
|
||||
.row-time { color: #c3c3c3; font-size: min(46px, calc(var(--row-height) * .43)); font-variant-numeric: tabular-nums; letter-spacing: -.025em; }
|
||||
.transport-notice { display: flex; align-items: center; gap: 10px; color: var(--warning); padding: 10px 0 0; font-size: clamp(15px, 1.4vw, 26px); flex: 0 0 auto; }
|
||||
.empty-departures { display: flex; flex-direction: column; justify-content: center; min-height: 0; }
|
||||
.empty-departures > svg { color: var(--accent); }
|
||||
.empty-departures h2 { font-size: clamp(24px, 3vw, 54px); font-weight: 550; margin: 10px 0; letter-spacing: -.04em; }
|
||||
.empty-departures p, .empty-following { color: #bdbdbd; font-size: clamp(14px, 1.5vw, 28px); margin: 0; line-height: 1.4; }
|
||||
.quiet-placeholder { display: none; }
|
||||
.offline-banner { display: flex; align-items: center; gap: 9px; color: var(--warning); font-size: clamp(13px, 1.2vw, 22px); padding-top: 9px; flex: 0 0 auto; }
|
||||
.offline-banner button { color: inherit; border: 0; background: none; display: flex; padding: 3px; margin-left: auto; }
|
||||
|
||||
/* Secondary content occupies the same bounded screen area. */
|
||||
.info-screen { height: 100%; min-height: 0; display: flex; flex-direction: column; padding: 18px 0; overflow-y: auto; scrollbar-width: thin; scrollbar-color: #555 #000; }
|
||||
.info-intro { flex-shrink: 0; display: flex; justify-content: space-between; align-items: center; gap: 16px; margin-bottom: 18px; }
|
||||
.info-intro h2 { margin: 0; font-size: clamp(28px, 3.5vw, 62px); letter-spacing: -.045em; font-weight: 550; }
|
||||
.information-tabs { display: none; gap: 10px; }
|
||||
.information-tabs button { font-size: 14px; border: 1px solid #484848; background: transparent; padding: 8px 12px; border-radius: 8px; color: #bdbdbd; }
|
||||
.information-tabs button[aria-current='page'] { color: #000; border-color: var(--accent); background: var(--accent); }
|
||||
.info-grid { flex: 1 1 0; min-height: 0; display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 24px; }
|
||||
.info-card { min-height: 0; border: 1px solid #3b3b3b; border-radius: 18px; padding: clamp(18px, 2vw, 38px); display: flex; flex-direction: column; overflow-y: auto; container-type: size; }
|
||||
.card-icon { color: var(--accent); margin-bottom: 18px; }
|
||||
.card-icon svg { width: 32px; height: 32px; }
|
||||
.info-card h3 { font-size: clamp(23px, 2vw, 38px); line-height: 1.2; font-weight: 600; letter-spacing: -.03em; margin: 0 0 16px; }
|
||||
.info-card p { color: #c8c8c8; font-size: clamp(18px, 1.75vw, 32px); line-height: 1.5; margin: 0 0 20px; }
|
||||
.info-caption { display: flex; align-items: center; gap: 9px; font-size: clamp(14px, 1.3vw, 23px); color: #bdbdbd; margin-top: auto; line-height: 1.4; }
|
||||
.info-caption svg { color: var(--accent); }
|
||||
.route-cloud { display: flex; flex-wrap: wrap; gap: 9px; margin-bottom: 16px; }
|
||||
.route-cloud > span { display: inline-flex; align-items: center; justify-content: center; min-width: 48px; height: 42px; padding: 0 10px; border: 1px solid #555; border-radius: 8px; font-size: clamp(20px, 1.5vw, 28px); font-weight: 550; }
|
||||
.routes-info p { margin: auto 0 0; font-size: clamp(16px, 1.4vw, 25px); }
|
||||
.ridebus-link { display: flex; gap: 10px; align-items: center; color: var(--accent); font-size: clamp(28px, 3vw, 54px); letter-spacing: -.045em; text-decoration: none; margin: auto 0 20px; }
|
||||
.cache-caption { display: flex; gap: 9px; align-items: center; color: var(--accent); font-size: clamp(15px, 1.25vw, 24px); line-height: 1.35; margin-top: auto; }
|
||||
.routes-info .schedule-explanation { margin: 12px 0 0; font-size: clamp(14px, 1.1vw, 21px); line-height: 1.35; }
|
||||
.weather-current { display: flex; align-items: center; gap: 20px; color: var(--accent); }
|
||||
.weather-current strong { font-size: clamp(64px, 5.5vw, 106px); line-height: 1.15; font-weight: 550; }
|
||||
.weather-info .weather-description { margin: 6px 0 12px; font-size: clamp(20px, 1.7vw, 32px); line-height: 1.2; }
|
||||
.weather-details { display: flex; flex-wrap: wrap; gap: 6px 18px; color: #c3c3c3; font-size: clamp(15px, 1.15vw, 23px); }
|
||||
.weather-details > span { display: flex; align-items: center; gap: 6px; }
|
||||
.weather-hours { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; border-top: 1px solid var(--line); margin-top: 18px; padding-top: 14px; }
|
||||
.weather-hours > div { display: flex; flex-direction: column; align-items: center; gap: 8px; }
|
||||
.weather-hours time { color: #bdbdbd; font-size: clamp(14px, 1vw, 22px); }
|
||||
.weather-hours svg { color: var(--accent); }
|
||||
.weather-hours strong { font-size: clamp(22px, 1.8vw, 34px); font-weight: 550; }
|
||||
.precipitation-chance { font-size: clamp(14px, 1vw, 21px); color: #c3c3c3; margin-top: 14px; line-height: 1.35; }
|
||||
.weather-attribution { margin-top: auto; padding-top: 14px; display: flex; flex-wrap: wrap; gap: 5px 12px; font-size: clamp(12px, .8vw, 17px); color: #aaa; }
|
||||
.weather-attribution a { color: inherit; text-underline-offset: 3px; }
|
||||
.qr-info { align-items: center; text-align: center; }
|
||||
.qr-content { margin: auto 0; min-height: 0; display: flex; align-items: center; flex-direction: column; }
|
||||
.passenger-qr { width: clamp(130px, 23vh, 248px); max-width: 100%; aspect-ratio: 1; }
|
||||
.qr-content p { font-size: clamp(16px, 1.25vw, 24px); line-height: 1.3; margin: 14px 0; }
|
||||
.passenger-link { display: flex; align-items: center; gap: 9px; color: var(--accent); text-decoration: none; font-size: clamp(17px, 1.4vw, 27px); margin-top: auto; }
|
||||
@media (max-height: 850px) {
|
||||
.info-screen { padding: 12px 0; }
|
||||
.info-intro { margin-bottom: 12px; }
|
||||
.info-intro h2 { font-size: 32px; }
|
||||
.info-status { padding-top: 12px; font-size: 16px; }
|
||||
.persistent-next .countdown > span { font-size: 40px; }
|
||||
.routes-info .card-icon { display: none; }
|
||||
.routes-info .route-cloud { gap: 7px; }
|
||||
.routes-info .route-cloud > span { height: 32px; min-width: 40px; padding: 0 7px; font-size: 18px; }
|
||||
.info-card { padding: 20px; }
|
||||
}
|
||||
@container (max-height: 370px) {
|
||||
.info-card h3 { margin-bottom: 12px; font-size: 24px; }
|
||||
.weather-current strong { font-size: 56px; }
|
||||
.weather-current svg { width: 44px; height: 44px; }
|
||||
.weather-info .weather-description { font-size: 20px; margin: 4px 0 10px; }
|
||||
.weather-details { font-size: 14px; }
|
||||
.weather-hours { margin-top: 12px; padding-top: 10px; }
|
||||
.weather-hours > div { display: grid; grid-template-columns: auto auto; justify-content: center; align-items: center; gap: 6px; }
|
||||
.weather-hours time { grid-column: 1 / -1; text-align: center; font-size: 14px; }
|
||||
.weather-hours strong { font-size: 21px; }
|
||||
.precipitation-chance { font-size: 14px; margin-top: 10px; }
|
||||
.weather-attribution { padding-top: 10px; font-size: 11px; }
|
||||
.routes-info .schedule-explanation { display: none; }
|
||||
}
|
||||
.info-status { display: flex; align-items: center; gap: 10px; flex-shrink: 0; color: #c3c3c3; font-size: clamp(14px, 1.4vw, 25px); padding-top: 16px; line-height: 1.3; }
|
||||
.info-status svg { color: var(--accent); }
|
||||
.info-notices { display: grid; gap: 18px; }
|
||||
.notice { padding: 24px; border: 1px solid #586447; border-radius: 16px; }
|
||||
.notice-warning { border-color: #76603a; }
|
||||
.notice-title { color: var(--accent); display: flex; align-items: center; gap: 10px; font-size: 20px; }
|
||||
.notice-warning .notice-title, .notice-warning .notice-routes { color: var(--warning); }
|
||||
.notice-routes { margin-top: 15px; font-size: 24px; }
|
||||
.notice h3 { font-size: clamp(24px, 2.4vw, 42px); font-weight: 600; line-height: 1.2; margin: 15px 0; }
|
||||
.notice p { font-size: clamp(18px, 1.8vw, 32px); line-height: 1.5; color: #d1d1d1; margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.persistent-next { display: flex; align-items: center; gap: 20px; border-top: 1px solid var(--line); padding: 12px 0; flex: 0 0 auto; --row-height: 72px; }
|
||||
.persistent-next .route-badge { width: 78px; height: 48px; font-size: 31px; padding: 0 10px; border-radius: 9px; }
|
||||
.persistent-next strong { min-width: 0; font-weight: 600; font-size: clamp(21px, 2.4vw, 42px); line-height: 1.15; }
|
||||
.persistent-next .countdown { margin-left: auto; }
|
||||
.persistent-next .countdown-future { flex-direction: row; align-items: baseline; }
|
||||
.ad-screen { padding: 4vh 0; height: 100%; overflow-y: auto; }
|
||||
.ad-screen h2 { font-size: clamp(44px, 7vw, 135px); color: var(--accent); line-height: 1.06; letter-spacing: -.055em; font-weight: 550; margin: 4vh 0 3vh; overflow-wrap: anywhere; }
|
||||
.ad-screen p { font-size: clamp(22px, 2.7vw, 50px); color: #ccc; line-height: 1.4; margin: 0; }
|
||||
.ad-signature { display: flex; align-items: center; gap: 15px; color: #bbb; margin-top: 4vh; font-size: 22px; }
|
||||
|
||||
/* Footer is a normal flex item, never an overlay. */
|
||||
.board-footer { flex: 0 0 auto; position: relative; border-top: 1px solid var(--line); view-transition-name: board-footer; }
|
||||
.cycle-progress { position: absolute; top: -1px; width: 100%; height: 1px; }
|
||||
.cycle-progress > span { display: block; width: 100%; height: 1px; background: var(--accent); transform-origin: left; transition: transform 1s linear; }
|
||||
.footer-main { display: flex; align-items: center; justify-content: space-between; gap: 14px; min-height: 58px; padding: 8px 0; }
|
||||
.scene-navigation { display: flex; align-items: center; gap: 22px; }
|
||||
.scene-navigation button { border: 0; padding: 4px 0; background: none; color: #bdbdbd; display: flex; align-items: center; gap: 9px; font-size: clamp(13px, 1.05vw, 20px); white-space: nowrap; }
|
||||
.scene-navigation button.active { color: #fff; }
|
||||
.scene-index { display: grid; place-items: center; width: 32px; height: 32px; border: 1px solid #555; border-radius: 50%; font-size: 13px; }
|
||||
.active .scene-index { color: #000; background: var(--accent); border-color: var(--accent); }
|
||||
.footer-status { display: flex; align-items: center; gap: 8px; color: #aaa; margin-left: auto; font-size: clamp(12px, .95vw, 18px); }
|
||||
.board-controls { display: flex; gap: 6px; flex-shrink: 0; opacity: 1; transition: opacity 250ms ease, transform 250ms ease; }
|
||||
.controls-idle, .controls-idle * { cursor: none; }
|
||||
.controls-idle .board-controls { opacity: 0; transform: translateY(3px); pointer-events: none; }
|
||||
.icon-button { display: inline-flex; justify-content: center; align-items: center; width: 40px; height: 40px; color: #c0c0c0; border: 1px solid transparent; border-radius: 9px; background: none; transition: color 180ms, border-color 180ms; }
|
||||
.icon-button:hover { color: #fff; border-color: #555; }
|
||||
.toast { position: fixed; bottom: 80px; left: 50%; transform: translateX(-50%); max-width: 90vw; z-index: 20; padding: 15px 22px; background: #171717; border: 1px solid #555; border-radius: 12px; font-size: 15px; }
|
||||
|
||||
/* Rolling digits keep tabular widths; only changed positions animate. */
|
||||
.numeric-text { display: inline-block; vertical-align: middle; line-height: 1.12; letter-spacing: 0; }
|
||||
.numeric-track { display: inline-flex; align-items: center; height: 1.12em; white-space: nowrap; }
|
||||
.numeric-glyph { position: relative; display: inline-block; width: .62em; height: 1.12em; overflow: hidden; line-height: 1.12; text-align: center; flex-shrink: 0; }
|
||||
.numeric-symbol { display: inline-block; line-height: 1.12; }
|
||||
.numeric-current { display: block; height: 1.12em; }
|
||||
.numeric-old { position: absolute; left: 0; top: 0; width: 100%; height: 1.12em; }
|
||||
.numeric-glyph[data-rolling='true'] .numeric-current { animation: digit-up-in 380ms cubic-bezier(.22,.8,.22,1) both; }
|
||||
.numeric-glyph[data-rolling='true'] .numeric-old { animation: digit-up-out 380ms cubic-bezier(.22,.8,.22,1) both; }
|
||||
.numeric-glyph[data-direction='down'][data-rolling='true'] .numeric-current { animation-name: digit-down-in; }
|
||||
.numeric-glyph[data-direction='down'][data-rolling='true'] .numeric-old { animation-name: digit-down-out; }
|
||||
.numeric-vanish { width: 0; animation: digit-collapse 380ms ease both; }
|
||||
@keyframes digit-up-in { from { transform: translateY(105%); } to { transform: translateY(0); } }
|
||||
@keyframes digit-up-out { from { transform: translateY(0); } to { transform: translateY(-105%); } }
|
||||
@keyframes digit-down-in { from { transform: translateY(-105%); } to { transform: translateY(0); } }
|
||||
@keyframes digit-down-out { from { transform: translateY(0); } to { transform: translateY(105%); } }
|
||||
@keyframes digit-collapse { from { width: .62em; } to { width: 0; } }
|
||||
@keyframes scene-in { from { opacity: 0; } to { opacity: 1; } }
|
||||
|
||||
/* Operator settings may scroll independently of the board. */
|
||||
.settings-dialog { width: min(600px, calc(100vw - 28px)); max-height: calc(100dvh - 32px); padding: 0; color: #eee; background: #0a0a0a; border: 1px solid #444; border-radius: 20px; overflow: auto; box-shadow: 0 25px 90px #000; }
|
||||
.settings-dialog::backdrop { background: #000c; backdrop-filter: blur(8px); }
|
||||
.settings-heading { display: flex; justify-content: space-between; align-items: center; gap: 16px; padding: 25px 28px; border-bottom: 1px solid var(--line); }
|
||||
.settings-heading h2 { margin: 8px 0 0; font-size: 27px; font-weight: 600; letter-spacing: -.04em; }
|
||||
.settings-heading .eyebrow { font-size: 11px; }
|
||||
.settings-body { padding: 0 28px 20px; }
|
||||
.settings-body section { border-bottom: 1px solid #303030; padding: 21px 0 14px; }
|
||||
.settings-body h3 { margin: 0 0 17px; font-size: 18px; font-weight: 650; }
|
||||
.settings-note { color: #b5b5b5; font-size: 13px; margin-bottom: 18px; }
|
||||
.settings-body label { display: flex; flex-direction: column; gap: 9px; font-size: 14px; color: #c8c8c8; margin: 17px 0; }
|
||||
.settings-body select, .settings-body input:not([type='checkbox']):not([type='radio']), .settings-body textarea { width: 100%; color: #f5f5f5; background: #141414; border: 1px solid #555; border-radius: 8px; padding: 12px; font-size: 15px; }
|
||||
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
.settings-body .switch-row { flex-direction: row; align-items: center; justify-content: space-between; gap: 15px; color: #eee; }
|
||||
.switch-row small { display: block; color: #b3b3b3; margin-top: 5px; font-size: 12px; line-height: 1.5; }
|
||||
.switch-row input { appearance: none; position: relative; flex-shrink: 0; margin: 0; width: 40px; height: 24px; background: #303030; border: 1px solid #606060; border-radius: 20px; transition: background 180ms; cursor: pointer; }
|
||||
.switch-row input::after { content: ''; position: absolute; top: 3px; left: 3px; width: 16px; height: 16px; border-radius: 50%; background: #ccc; transition: transform 180ms; }
|
||||
.switch-row input:checked { background: var(--accent); border-color: var(--accent); }
|
||||
.switch-row input:checked::after { transform: translateX(16px); background: #000; }
|
||||
.theme-picker { border: 0; padding: 0; margin: 0; display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||
.settings-body .theme-option { flex-direction: row; align-items: center; gap: 10px; padding: 12px; margin: 0; border: 1px solid #414141; border-radius: 9px; cursor: pointer; }
|
||||
.theme-option:has(input:checked) { border-color: var(--accent); color: #fff; }
|
||||
.theme-option input { accent-color: var(--accent); margin: 0; width: 16px; height: 16px; }
|
||||
.theme-swatch { width: 22px; height: 22px; border-radius: 50%; flex-shrink: 0; }
|
||||
.tiny-tag { font-size: 9px; color: var(--accent); border: 1px solid #555; border-radius: 4px; padding: 3px 5px; margin-left: 7px; letter-spacing: .07em; }
|
||||
.text-button { display: inline-flex; align-items: center; gap: 9px; border: 0; background: none; color: #c3c3c3; font-size: 14px; padding: 10px 0; }
|
||||
.settings-body > .text-button { margin-top: 15px; }
|
||||
.form-hint { font-size: 12px; color: #b3b3b3; line-height: 1.6; }
|
||||
.settings-actions { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 20px 28px; border-top: 1px solid var(--line); }
|
||||
.primary-button { display: inline-flex; align-items: center; gap: 8px; background: var(--accent); color: #000; border: 0; border-radius: 9px; padding: 13px 22px; font-size: 15px; font-weight: 650; }
|
||||
|
||||
@media (max-width: 1050px) { .brand-word { display: none; } .header-right { column-gap: 18px; } .row-destination p { display: none; } .footer-status { max-width: 180px; font-size: 12px; } }
|
||||
@media (orientation: portrait), (max-width: 850px) {
|
||||
.board { padding: 16px 22px 0; }
|
||||
h1 { font-size: clamp(30px, 5.7vw, 62px); }
|
||||
.stop-direction { font-size: clamp(16px, 3vw, 28px); }
|
||||
.brand-word { display: none; }
|
||||
.clock { font-size: clamp(35px, 6.2vw, 68px); }
|
||||
.today { font-size: clamp(12px, 2.1vw, 20px); }
|
||||
.hero-destination h2 { font-size: min(6vw, calc(var(--hero-height) * .33)); }
|
||||
.transport-kind { font-size: clamp(14px, 2.5vw, 24px); }
|
||||
.hero-details { font-size: clamp(12px, 2.4vw, 22px); }
|
||||
.hero-departure { gap: 18px; }
|
||||
.countdown-large small { font-size: clamp(17px, 3vw, 28px); }
|
||||
.countdown-large .countdown-now { font-size: min(5.2vw, calc(var(--hero-height) * .4)); }
|
||||
.row-destination h3 { font-size: min(46px, 5.7vw, calc(var(--row-height) * .43)); }
|
||||
.row-time { font-size: min(36px, calc(var(--row-height) * .35)); }
|
||||
.departure-row { grid-template-columns: auto minmax(0, 1fr) auto auto; gap: 18px; }
|
||||
.departure-row .route-badge { width: clamp(68px, 12vw, 126px); }
|
||||
.info-intro { flex-direction: column; align-items: flex-start; gap: 12px; }
|
||||
.information-tabs { display: flex; }
|
||||
.info-grid { grid-template-columns: 1fr; }
|
||||
.info-card:not([data-active='true']) { display: none; }
|
||||
.info-card { padding: 24px; }
|
||||
.info-card h3 { font-size: clamp(26px, 4vw, 40px); }
|
||||
.info-card p { font-size: clamp(20px, 3.2vw, 34px); }
|
||||
.route-cloud > span { height: 44px; min-width: 54px; font-size: 24px; }
|
||||
.info-caption, .info-status { font-size: clamp(15px, 2.4vw, 24px); }
|
||||
.persistent-next { gap: 13px; }
|
||||
.persistent-next > .eyebrow, .persistent-next > .source-label { display: none; }
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
.board { padding: 12px 16px 0; }
|
||||
.board-header { gap: 14px; padding-bottom: 12px; }
|
||||
.header-right { column-gap: 10px; }
|
||||
.official-mark { width: 32px; }
|
||||
.clock { font-size: clamp(28px, 6.8vw, 44px); gap: 4px; }
|
||||
h1 { font-size: clamp(27px, 6.5vw, 42px); }
|
||||
.today { font-size: 11px; }
|
||||
.stop-direction { font-size: 15px; margin-top: 7px; gap: 7px; }
|
||||
.stop-direction svg { width: 18px; height: 18px; }
|
||||
.stop-meta { gap: 5px 12px; }
|
||||
.weather-summary { font-size: 13px; gap: 6px; margin-top: 7px; }
|
||||
.weather-summary svg { width: 20px; height: 20px; }
|
||||
.weather-summary > span { display: none; }
|
||||
.source-label { font-size: 12px; gap: 5px; }
|
||||
.eyebrow { font-size: 11px; }
|
||||
.hero-departure { gap: 13px; }
|
||||
.hero-destination h2 { font-size: min(6.3vw, calc(var(--hero-height) * .32)); }
|
||||
.transport-kind { font-size: 12px; gap: 5px; margin-bottom: 4px; }
|
||||
.transport-kind svg { width: 18px; height: 18px; }
|
||||
.hero-details { display: none; }
|
||||
.hero-details .schedule-time { display: none; }
|
||||
.route-badge-large { width: calc(var(--hero-height) * .72); padding: 0 12px; font-size: calc(var(--hero-height) * .55); }
|
||||
.countdown-large > span { font-size: calc(var(--hero-height) * .6); }
|
||||
.countdown-future > span { font-size: min(24px, calc(var(--row-height) * .36)); }
|
||||
.countdown-large.countdown-future > span { font-size: min(28px, calc(var(--hero-height) * .35)); }
|
||||
.countdown-large .countdown-now { font-size: 5vw; }
|
||||
.countdown-large small { font-size: 14px; }
|
||||
.countdown { gap: 5px; }
|
||||
.departure-row { gap: 12px; }
|
||||
.departure-row .route-badge { width: 65px; padding: 0 9px; }
|
||||
.row-time { display: none; }
|
||||
.departure-row { grid-template-columns: auto minmax(0, 1fr) auto; }
|
||||
.row-destination h3 { font-size: min(5.8vw, calc(var(--row-height) * .43)); }
|
||||
.countdown > span { font-size: min(36px, calc(var(--row-height) * .6)); }
|
||||
.countdown small { font-size: min(16px, calc(var(--row-height) * .25)); }
|
||||
.countdown-large > span { font-size: calc(var(--hero-height) * .6); }
|
||||
.scene-navigation { gap: 12px; }
|
||||
.scene-navigation button > span:last-child { display: none; }
|
||||
.footer-main { min-height: 50px; gap: 8px; padding: 6px 0; }
|
||||
.footer-status { font-size: 10px; max-width: 95px; }
|
||||
.board-controls { gap: 3px; }
|
||||
.icon-button { width: 32px; height: 32px; }
|
||||
.board-controls svg { width: 21px; height: 21px; }
|
||||
.persistent-next { --row-height: 54px; gap: 10px; padding: 9px 0; }
|
||||
.persistent-next .route-badge { height: 38px; width: 64px; font-size: 26px; }
|
||||
.persistent-next strong { font-size: 18px; }
|
||||
.persistent-next .countdown > span { font-size: 29px; }
|
||||
.persistent-next .countdown-now { font-size: 19px; }
|
||||
.info-screen { padding: 12px 0; }
|
||||
.info-intro { gap: 10px; margin-bottom: 12px; }
|
||||
.info-intro h2 { font-size: 30px; }
|
||||
.information-tabs { gap: 7px; }
|
||||
.information-tabs button { font-size: 12px; padding: 7px 10px; }
|
||||
.info-card { padding: 18px; }
|
||||
.card-icon { margin-bottom: 10px; }
|
||||
.card-icon svg { width: 25px; height: 25px; }
|
||||
.info-card h3 { font-size: 24px; margin-bottom: 12px; }
|
||||
.info-card p { font-size: 19px; }
|
||||
.weather-current strong { font-size: 68px; }
|
||||
.weather-info .weather-description { font-size: 20px; }
|
||||
.weather-hours { margin-top: 14px; padding-top: 10px; }
|
||||
.weather-attribution { padding-top: 12px; }
|
||||
.routes-info .schedule-explanation { display: none; }
|
||||
.qr-content p { font-size: 16px; margin: 12px 0; }
|
||||
.passenger-qr { width: min(200px, 25vh); }
|
||||
.route-cloud { gap: 7px; }
|
||||
.route-cloud > span { height: 35px; min-width: 43px; padding: 0 7px; font-size: 19px; }
|
||||
.info-status { font-size: 13px; padding-top: 12px; }
|
||||
.transport-notice, .offline-banner { font-size: 12px; }
|
||||
.settings-heading, .settings-body, .settings-actions { padding-left: 20px; padding-right: 20px; }
|
||||
}
|
||||
@media (max-height: 620px) {
|
||||
.board { --hero-height: 90px; padding-top: 10px; }
|
||||
.board[data-row-count='5'], .board[data-row-count='6'] { --hero-height: 76px; }
|
||||
.board-header { padding-bottom: 10px; }
|
||||
.clock { font-size: 37px; }
|
||||
h1 { font-size: 32px; }
|
||||
.stop-direction { font-size: 15px; }
|
||||
.official-mark { width: 35px; }
|
||||
.brand { font-size: 26px; }
|
||||
.today { font-size: 12px; }
|
||||
.info-card { padding: 16px; }
|
||||
.card-icon { display: none; }
|
||||
.info-intro h2 { font-size: 28px; }
|
||||
}
|
||||
@media (orientation: portrait) and (max-height: 850px) {
|
||||
.routes-info .card-icon, .routes-info p { display: none; }
|
||||
.routes-info .route-cloud { margin-bottom: 0; gap: 6px; }
|
||||
.routes-info .route-cloud > span { height: 32px; min-width: 40px; font-size: 18px; }
|
||||
}
|
||||
@media (orientation: portrait) and (max-height: 700px) {
|
||||
.weather-info { padding: 14px; }
|
||||
.info-intro h2 { font-size: 26px; }
|
||||
.info-intro { gap: 8px; }
|
||||
.info-status { font-size: 12px; padding-top: 8px; line-height: 1.25; }
|
||||
.weather-info h3 { margin-bottom: 6px; }
|
||||
.weather-current strong { font-size: 48px; }
|
||||
.weather-current svg { width: 40px; height: 40px; }
|
||||
.weather-info .weather-description { font-size: 18px; margin: 4px 0 8px; }
|
||||
.weather-hours { margin-top: 8px; padding-top: 6px; }
|
||||
.weather-hours > div { gap: 5px; }
|
||||
.precipitation-chance { margin-top: 8px; }
|
||||
.weather-attribution { padding-top: 6px; font-size: 11px; }
|
||||
.passenger-qr { width: 150px; }
|
||||
.qr-info h3 { font-size: 23px; }
|
||||
}
|
||||
::view-transition { pointer-events: none; }
|
||||
::view-transition-old(root), ::view-transition-new(root) { animation-duration: 320ms; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { animation-duration: .01ms !important; animation-delay: 0ms !important; transition-duration: .01ms !important; scroll-behavior: auto !important; }
|
||||
.numeric-old { visibility: hidden; }
|
||||
.numeric-current { transform: none !important; }
|
||||
}
|
||||
18
src/types.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export type Departure = {
|
||||
id: string; routeId: number; routeNumber: string; routeName: string; destination: string;
|
||||
type: string; scheduledAt: string; scheduledTime: string; note: string; source: 'schedule';
|
||||
};
|
||||
export type Route = { id: number; number: string; name: string; type: string };
|
||||
export type Alert = { id: string; title: string; message: string; type: string; routeNumbers: string[]; routeIds: number[]; startAt: string | null; endAt: string | null };
|
||||
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 Settings = { stopId: number; demo: boolean; mainSeconds: number; infoSeconds: number; holdNear: boolean;
|
||||
rowCount: number; routeFilter: string; colorScheme: string; adsEnabled: boolean; adSeconds: number; adTitle: string; adText: string };
|
||||
export type Scene = 'departures' | 'info' | 'ad';
|
||||
export type Timetable = { version: 1; cityId: number; cityName: string; stop: Stop; routes: (Route & { schedules: {
|
||||
dayType: string; validFrom: string | null; validUntil: string | null; trips: { arrivalSeconds: number; destination: string; note: string; daysOfWeek: number[]; exceptDaysOfWeek: number[] }[];
|
||||
}[] })[]; holidays: string[]; calendarYears: number[]; updatedAt: string; expiresAt: string };
|
||||
export type Weather = { cityId: number; stopId: number; updatedAt: string; observedAt: string; temperature: number; apparentTemperature: number;
|
||||
code: number; isDay: boolean; windSpeed: number; hourly: { at: string; temperature: number; code: number; precipitationProbability: number }[] };
|
||||
37
src/useIdleControls.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
export function useIdleControls(locked: boolean, delay = 5000) {
|
||||
const [visible, setVisible] = useState(true);
|
||||
const keyboard = useRef(false);
|
||||
useEffect(() => {
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
function reveal() {
|
||||
setVisible(true);
|
||||
clearTimeout(timer);
|
||||
if (locked) return;
|
||||
timer = setTimeout(() => {
|
||||
// Keyboard users must not lose a focused control mid-interaction.
|
||||
if (keyboard.current && document.activeElement?.closest('.board-controls')) return;
|
||||
setVisible(false);
|
||||
}, delay);
|
||||
}
|
||||
function pointerActivity() { keyboard.current = false; reveal(); }
|
||||
function keyboardActivity() { keyboard.current = true; reveal(); }
|
||||
function focusActivity() { if (keyboard.current) reveal(); }
|
||||
reveal();
|
||||
window.addEventListener('pointermove', pointerActivity, { passive: true });
|
||||
window.addEventListener('pointerdown', pointerActivity, { passive: true });
|
||||
window.addEventListener('keydown', keyboardActivity);
|
||||
window.addEventListener('focusin', focusActivity);
|
||||
window.addEventListener('focusout', focusActivity);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
window.removeEventListener('pointermove', pointerActivity);
|
||||
window.removeEventListener('pointerdown', pointerActivity);
|
||||
window.removeEventListener('keydown', keyboardActivity);
|
||||
window.removeEventListener('focusin', focusActivity);
|
||||
window.removeEventListener('focusout', focusActivity);
|
||||
};
|
||||
}, [locked, delay]);
|
||||
return visible || locked;
|
||||
}
|
||||
44
test/api.test.mjs
Normal file
@@ -0,0 +1,44 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createApp } from '../server/index.mjs';
|
||||
import { ApiError } from '../server/board.mjs';
|
||||
|
||||
test('board HTTP API validates inputs, rejects writes, and hides upstream errors', async t => {
|
||||
const client = { config: () => ({ configured: true, cityId: 1 }), stops: async () => [{ id: 1, name: 'Швейная фабрика' }],
|
||||
timetable: async id => ({ stop: { id }, routes: [] }), coordinates: async () => ({ latitude: 55, longitude: 28 }),
|
||||
board: async id => { if (id === 2) throw new Error('secret-api-key and upstream response'); if (id === 3) throw new ApiError(404, 'stop_not_found'); return { stop: { id }, departures: [] }; } };
|
||||
const weatherClient = { weather: async (cityId, stopId) => ({ cityId, stopId, temperature: 12 }) };
|
||||
const server = createApp(client, weatherClient).listen(0, '127.0.0.1');
|
||||
await new Promise((resolve, reject) => { server.once('listening', resolve); server.once('error', reject); });
|
||||
t.after(() => new Promise(resolve => server.close(resolve)));
|
||||
const base = `http://127.0.0.1:${server.address().port}`;
|
||||
await t.test('successful GET returns no-store and server time', async () => {
|
||||
const response = await fetch(base + '/api/board?stopId=1');
|
||||
assert.equal(response.status, 200); assert.equal(response.headers.get('cache-control'), 'no-store');
|
||||
const body = await response.json(); assert.equal(body.stop.id, 1); assert.ok(Number.isFinite(Date.parse(body.serverTime)));
|
||||
});
|
||||
await t.test('invalid and missing IDs return 400', async () => {
|
||||
for (const endpoint of ['board', 'timetable', 'weather']) for (const query of ['', '?stopId=0', '?stopId=-1', '?stopId=abc', '?stopId=1&stopId=2']) assert.equal((await fetch(base + '/api/' + endpoint + query)).status, 400);
|
||||
});
|
||||
await t.test('full timetable and weather use only the selected platform and no-store', async () => {
|
||||
const timetable = await fetch(base + '/api/timetable?stopId=2');
|
||||
assert.equal(timetable.headers.get('cache-control'), 'no-store'); assert.equal((await timetable.json()).stop.id, 2);
|
||||
assert.deepEqual(await (await fetch(base + '/api/weather?stopId=2')).json(), { cityId: 1, stopId: 2, temperature: 12 });
|
||||
});
|
||||
await t.test('writes are never forwarded', async () => {
|
||||
const response = await fetch(base + '/api/board?stopId=1', { method: 'POST' });
|
||||
assert.equal(response.status, 405); assert.equal(response.headers.get('allow'), 'GET');
|
||||
});
|
||||
await t.test('raw upstream errors and credentials never reach the browser', async () => {
|
||||
const response = await fetch(base + '/api/board?stopId=2');
|
||||
assert.equal(response.status, 502); assert.deepEqual(await response.json(), { error: 'data_unavailable' });
|
||||
});
|
||||
await t.test('not-found and unknown endpoints keep their status', async () => {
|
||||
assert.equal((await fetch(base + '/api/board?stopId=3')).status, 404);
|
||||
assert.equal((await fetch(base + '/api/unrestricted-proxy')).status, 404);
|
||||
});
|
||||
await t.test('config contains only public settings', async () => {
|
||||
assert.deepEqual(await (await fetch(base + '/api/config')).json(), { configured: true, cityId: 1 });
|
||||
assert.equal((await fetch(base + '/healthz')).status, 200);
|
||||
});
|
||||
});
|
||||
126
test/board.test.mjs
Normal file
@@ -0,0 +1,126 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { ApiError, createBoardClient, normalizeAlerts, normalizeDepartures, scheduledInstant } from '../server/board.mjs';
|
||||
import { activeAlerts, isBoardData, isFresh, minutesUntil, shouldHoldScene, upcomingDepartures } from '../src/lib/board.mjs';
|
||||
|
||||
const now = Date.parse('2026-10-08T12:30:40+03:00');
|
||||
const routes = [{ id: 37, number: '5', name: 'Полоцк — Новополоцк', type: 'bus' }];
|
||||
const raw = { routes: [{ id: 37, route_number: '5', name: 'Полоцк — Новополоцк', arrivals: [
|
||||
{ scheduledTime: '12:35:00', estimatedTime: '12:40:00', delay: 5, minutesUntil: 10, trip: { note: 'Через Экимань' } },
|
||||
{ scheduledTime: '12:25:00', estimatedTime: '12:40:00', delay: 15, minutesUntil: 10 },
|
||||
] }] };
|
||||
const makeSnapshot = () => ({ cityId: 1, stop: { id: 1 }, mode: 'live', serviceDate: '2026-10-08', updatedAt: new Date(now).toISOString(),
|
||||
...normalizeDepartures(raw, now), alerts: [] });
|
||||
|
||||
test('schedule display uses exact Minsk wall-clock time, excluding historical delay', () => {
|
||||
const result = normalizeDepartures(raw, now);
|
||||
assert.equal(result.departures.length, 1);
|
||||
assert.equal(result.departures[0].scheduledAt, '2026-10-08T12:35:00+03:00');
|
||||
assert.equal(result.departures[0].destination, 'Новополоцк');
|
||||
assert.equal(result.departures[0].note, 'Через Экимань');
|
||||
assert.equal(minutesUntil(result.departures[0], now), 5);
|
||||
});
|
||||
test('invalid clock values do not create false arrivals', () => {
|
||||
assert.equal(scheduledInstant('25:01', now), null);
|
||||
assert.equal(scheduledInstant('12:99', now), null);
|
||||
assert.equal(scheduledInstant(null, now), null);
|
||||
assert.equal(scheduledInstant('9:01', now), '2026-10-08T09:01:00+03:00');
|
||||
});
|
||||
test('partial route failures preserve working routes and label incomplete data', () => {
|
||||
const result = normalizeDepartures({ routes: [...raw.routes, { id: 3, route_number: '6', name: 'А — Б', error: 'Failed' }] }, now);
|
||||
assert.equal(result.partial, true);
|
||||
assert.equal(result.departures.length, 1);
|
||||
});
|
||||
test('malformed ETA response is not reported as an empty timetable', () => {
|
||||
assert.throws(() => normalizeDepartures({}, now), ApiError);
|
||||
});
|
||||
test('partial trips use the actual terminal stop when available', () => {
|
||||
const result = normalizeDepartures({ routes: [{ ...raw.routes[0], arrivals: [{ scheduledTime: '12:35', trip: { endStopId: 99 } }] }] }, now, new Map([[99, 'Автовокзал']]));
|
||||
assert.equal(result.departures[0].destination, 'Автовокзал');
|
||||
});
|
||||
test('alerts match arrays before legacy scalar fields; unrelated and foreign-city alerts are excluded', () => {
|
||||
const alerts = [
|
||||
{ id: 1, route_id: null, route_ids: [37], route_numbers: ['5', '27'], title: 'Изменения', message: 'Полный текст', city_id: 1 },
|
||||
{ id: 2, route_id: null, route_ids: [88], route_numbers: ['88'], city_id: 1 },
|
||||
{ id: 3, route_id: 37, city_id: 2 },
|
||||
{ id: 4, route_id: 37, city_id: 1 },
|
||||
{ id: 5, route_id: null, route_numbers: [], city_id: 1 },
|
||||
];
|
||||
const result = normalizeAlerts(alerts, routes, 1, now);
|
||||
assert.deepEqual(result.map(alert => alert.id), ['1', '4', '5']);
|
||||
assert.deepEqual(result[0].routeNumbers, ['5', '27']);
|
||||
assert.equal(result[0].message, 'Полный текст');
|
||||
});
|
||||
test('expired, future and duplicate warnings never rotate onto the display', () => {
|
||||
const result = normalizeAlerts([
|
||||
{ id: 1, end_time: '2026-10-08T12:00:00+03:00' },
|
||||
{ id: 2, start_time: '2026-10-08T13:00:00+03:00' },
|
||||
{ id: 3, title: 'Актуальное' }, { id: 3, title: 'Актуальное' },
|
||||
{ id: 4, start_time: 'invalid' },
|
||||
], routes, 1, now);
|
||||
assert.deepEqual(result.map(alert => alert.id), ['3']);
|
||||
assert.equal(activeAlerts([{ ...result[0], endAt: new Date(now + 1000).toISOString() }], now + 1001).length, 0);
|
||||
});
|
||||
test('cached arrivals expire instead of counting down forever or rolling into tomorrow', () => {
|
||||
const snapshot = makeSnapshot();
|
||||
assert.equal(upcomingDepartures(snapshot, now).length, 1);
|
||||
assert.equal(upcomingDepartures(snapshot, Date.parse('2026-10-08T12:36:00+03:00')).length, 0);
|
||||
assert.equal(upcomingDepartures(snapshot, Date.parse('2026-10-09T00:01:00+03:00')).length, 0);
|
||||
});
|
||||
test('offline and old snapshots never report fresh data', () => {
|
||||
const data = makeSnapshot();
|
||||
assert.equal(isFresh(data, now, false), true);
|
||||
assert.equal(isFresh(data, now, true), false);
|
||||
assert.equal(isFresh(data, now + 90001, false), false);
|
||||
});
|
||||
test('number-only warnings respect the selected route while citywide warnings remain visible', () => {
|
||||
const common = { startAt: null, endAt: null, routeIds: [] };
|
||||
const alerts = [{ ...common, id: '1', routeNumbers: ['5'] }, { ...common, id: '2', routeNumbers: ['88'] }, { ...common, id: '3', routeNumbers: [] }];
|
||||
assert.deepEqual(activeAlerts(alerts, now, '37', routes).map(alert => alert.id), ['1', '3']);
|
||||
});
|
||||
test('city and platform identity are checked before restoring a snapshot', () => {
|
||||
const data = makeSnapshot();
|
||||
assert.equal(isBoardData(data, 1, 1), true);
|
||||
assert.equal(isBoardData(data, 2, 1), false);
|
||||
assert.equal(isBoardData(data, 1, 2), false);
|
||||
assert.equal(isBoardData({ ...data, mode: 'demo' }, 1, 1), false);
|
||||
});
|
||||
test('route filtering and transport priority use absolute arrival time', () => {
|
||||
const data = makeSnapshot();
|
||||
assert.equal(upcomingDepartures(data, now, '88').length, 0);
|
||||
assert.equal(shouldHoldScene({ holdNear: true }, data.departures[0], now), false);
|
||||
assert.equal(shouldHoldScene({ holdNear: true }, data.departures[0], now + 180000), true);
|
||||
assert.equal(shouldHoldScene({ holdNear: false }, data.departures[0], now + 180000), false);
|
||||
});
|
||||
|
||||
function fakeClient(options = {}) {
|
||||
const calls = [];
|
||||
const data = { 'api/v1/stops': [{ id: 1, name: 'Швейная фабрика', description: 'основная', city_id: 1 }],
|
||||
'api/v1/stops/1': { id: 1, name: 'Швейная фабрика', city_id: 1, routes: [{ id: 37, route_number: '5', name: 'Полоцк — Новополоцк' }] },
|
||||
'api/v1/eta/stop/1': raw, 'api/v1/alerts/active': [] };
|
||||
const client = createBoardClient({ apiKey: 'server-only-test-key', baseUrl: 'https://api.test/', now: () => now,
|
||||
fetchData: async (url, init) => {
|
||||
calls.push({ path: url.pathname, headers: init.headers });
|
||||
if (options.alertsFail && url.pathname.endsWith('/alerts/active')) return new Response('{}', { status: 500 });
|
||||
return new Response(JSON.stringify(data[url.pathname.slice(1)]), { headers: { 'Content-Type': 'application/json' } });
|
||||
} });
|
||||
return { client, calls };
|
||||
}
|
||||
test('concurrent board requests are coalesced; secrets stay server-side and city is passed upstream', async () => {
|
||||
const { client, calls } = fakeClient();
|
||||
const [first, second] = await Promise.all([client.board(1), client.board(1)]);
|
||||
assert.deepEqual(first, second);
|
||||
assert.equal(calls.filter(call => call.path === '/api/v1/eta/stop/1').length, 1);
|
||||
assert.ok(calls.every(call => call.headers['X-City-Id'] === '1'));
|
||||
assert.ok(!JSON.stringify(first).includes('server-only-test-key'));
|
||||
});
|
||||
test('alert API failure does not remove arrivals or claim no warnings', async () => {
|
||||
const { client } = fakeClient({ alertsFail: true });
|
||||
const data = await client.board(1);
|
||||
assert.equal(data.departures.length, 1);
|
||||
assert.equal(data.alertsAvailable, false);
|
||||
});
|
||||
test('missing configuration and unknown stops produce explicit errors', async () => {
|
||||
await assert.rejects(createBoardClient({ apiKey: '' }).stops(), error => error.status === 503 && error.code === 'api_not_configured');
|
||||
await assert.rejects(fakeClient().client.board(999), error => error.status === 404);
|
||||
});
|
||||
34
test/presentation.test.mjs
Normal file
@@ -0,0 +1,34 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { colorScheme, colorSchemes, departurePage, numericFrame, stopDirection } from '../src/lib/presentation.mjs';
|
||||
|
||||
test('all accent schemes remain legible against black and with black route numbers', () => {
|
||||
for (const scheme of colorSchemes) {
|
||||
const rgb = scheme.color.slice(1).match(/../g).map(value => parseInt(value, 16) / 255);
|
||||
const channels = rgb.map(value => value <= .04045 ? value / 12.92 : ((value + .055) / 1.055) ** 2.4);
|
||||
const luminance = .2126 * channels[0] + .7152 * channels[1] + .0722 * channels[2];
|
||||
assert.ok((luminance + .05) / .05 >= 7, scheme.id);
|
||||
}
|
||||
assert.equal(colorScheme('unknown').id, 'lime');
|
||||
});
|
||||
test('clock carry rolls changed positions while keeping separators and unchanged digits still', () => {
|
||||
const frame = numericFrame('09:59', '10:00', 959, 1000);
|
||||
assert.equal(frame.direction, 'up');
|
||||
assert.deepEqual(frame.glyphs.filter(glyph => glyph.changed).map(glyph => glyph.position), [4, 3, 1, 0]);
|
||||
assert.equal(frame.glyphs.find(glyph => glyph.character === ':').changed, false);
|
||||
assert.equal(numericFrame('12', '11', 12, 11).direction, 'down');
|
||||
assert.equal(numericFrame('10', '9', 10, 9).glyphs.at(-1).character, '9');
|
||||
});
|
||||
test('six departures fit in one page when space permits; short screens preserve all requested departures across balanced pages', () => {
|
||||
const items = Array.from({ length: 8 }, (_, id) => ({ id }));
|
||||
const full = departurePage(items, 6, 6, 0);
|
||||
assert.equal(full.items.length, 6); assert.equal(full.pages, 1);
|
||||
const first = departurePage(items, 6, 4, 0), second = departurePage(items, 6, 4, 1);
|
||||
assert.deepEqual([...first.items, ...second.items].map(item => item.id), [0, 1, 2, 3, 4, 5]);
|
||||
assert.equal(first.items.length, second.items.length);
|
||||
});
|
||||
test('direction comes from platform metadata without inventing one for the main platform', () => {
|
||||
assert.equal(stopDirection({ direction: 'в Новополоцк' }), 'В Новополоцк');
|
||||
assert.equal(stopDirection({ direction: 'основная' }), '');
|
||||
assert.equal(stopDirection({ direction: '' }), '');
|
||||
});
|
||||
32
test/storage.test.mjs
Normal file
@@ -0,0 +1,32 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readSnapshot, writeSnapshot, readTimetable, writeTimetable, readWeather, writeWeather } from '../src/lib/storage.ts';
|
||||
|
||||
const data = { cityId: 1, stop: { id: 1, name: 'Швейная фабрика' }, mode: 'live', serviceDate: '2026-10-08', updatedAt: '2026-10-08T12:00:00+03:00', routes: [], alerts: [],
|
||||
departures: [{ id: '1:12:30', scheduledAt: '2026-10-08T12:30:00+03:00' }] };
|
||||
|
||||
test('offline snapshot is restored when IndexedDB is unavailable in a kiosk WebView', async () => {
|
||||
const values = new Map();
|
||||
globalThis.localStorage = { getItem: key => values.get(key) || null, setItem: (key, value) => values.set(key, value) };
|
||||
globalThis.indexedDB = { open: () => { throw new Error('IndexedDB unavailable'); } };
|
||||
await writeSnapshot(data);
|
||||
assert.deepEqual(await readSnapshot(1, 1), data);
|
||||
assert.equal(await readSnapshot(2, 1), null);
|
||||
assert.equal(await readSnapshot(1, 2), null);
|
||||
});
|
||||
test('damaged saved data is rejected, and total storage failure is reported to the UI', async () => {
|
||||
globalThis.localStorage = { getItem: () => '{invalid', setItem: () => { throw new Error('Quota exceeded'); } };
|
||||
globalThis.indexedDB = { open: () => { throw new Error('IndexedDB unavailable'); } };
|
||||
assert.equal(await readSnapshot(1, 1), null);
|
||||
await assert.rejects(writeSnapshot(data));
|
||||
});
|
||||
test('full timetable and weather survive storage fallback and stay isolated by city and stop', async () => {
|
||||
const values = new Map();
|
||||
globalThis.localStorage = { getItem: key => values.get(key) || null, setItem: (key, value) => values.set(key, value) };
|
||||
globalThis.indexedDB = { open: () => { throw new Error('IndexedDB unavailable'); } };
|
||||
const timetable = { version: 1, cityId: 1, cityName: 'Полоцк', stop: { id: 1, name: 'Швейная фабрика', direction: '' }, routes: [], holidays: [], calendarYears: [2026], updatedAt: data.updatedAt, expiresAt: '2026-10-15T12:00:00+03:00' };
|
||||
const weather = { cityId: 1, stopId: 1, updatedAt: data.updatedAt, observedAt: data.updatedAt, temperature: 12, apparentTemperature: 10, code: 3, isDay: true, windSpeed: 3, hourly: [] };
|
||||
await writeTimetable(timetable); await writeWeather(weather); await writeSnapshot(data);
|
||||
assert.deepEqual(await readTimetable(1, 1), timetable); assert.deepEqual(await readWeather(1, 1), weather); assert.deepEqual(await readSnapshot(1, 1), data);
|
||||
assert.equal(await readTimetable(1, 2), null); assert.equal(await readWeather(2, 1), null);
|
||||
});
|
||||
64
test/timetable.test.mjs
Normal file
@@ -0,0 +1,64 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { loadTimetable } from '../server/timetable.mjs';
|
||||
import { isTimetable, localDate, timetableAvailable, timetableDepartures } from '../src/lib/timetable.mjs';
|
||||
import { passengerLink } from '../src/lib/passenger.mjs';
|
||||
|
||||
const now = Date.parse('2026-10-08T12:00:00+03:00');
|
||||
const route = { id: 37, route_number: '5', name: 'А — В', type: 'bus' };
|
||||
const stops = [{ stop_id: 1, sequence: 1, time_offset_minutes: 0, name: 'А' }, { stop_id: 2, sequence: 2, time_offset_minutes: 15, name: 'Б' }, { stop_id: 3, sequence: 3, time_offset_minutes: 30, name: 'В' }];
|
||||
const schedule = (day_type, trips, extra = {}) => ({ route_id: 37, day_type, trips, ...extra });
|
||||
async function bundle(schedules, options = {}) {
|
||||
return loadTimetable({ cityId: 1, cityName: 'Полоцк', stop: { id: 2, name: 'Б', routes: [route] }, now,
|
||||
upstream: async path => path.includes('holidays') ? options.holidays || [] : path.endsWith('/stops') ? stops : schedules });
|
||||
}
|
||||
test('full cache contains all trips and regenerates Friday and Saturday without network', async () => {
|
||||
const data = await bundle([schedule('weekday', ['08:00', '12:00', '15:00', '18:00', '22:00'].map(departureTime => ({ departureTime }))), schedule('saturday', [{ departureTime: '09:00' }])]);
|
||||
assert.equal(isTimetable(data, 1, 2), true);
|
||||
assert.equal(isTimetable(data, 1, 1), false);
|
||||
assert.equal(isTimetable({ ...data, routes: [{ ...data.routes[0], schedules: [{ dayType: 'weekday', trips: [{ arrivalSeconds: NaN }] }] }] }, 1, 2), false);
|
||||
const departures = timetableDepartures(data, '2026-10-09');
|
||||
assert.equal(departures.filter(item => localDate(Date.parse(item.scheduledAt)) === '2026-10-09').length, 5);
|
||||
assert.equal(departures.find(item => localDate(Date.parse(item.scheduledAt)) === '2026-10-10').scheduledTime, '09:15');
|
||||
assert.equal(timetableAvailable(data, now + 86400000), true);
|
||||
assert.equal(timetableAvailable(data, now + 7 * 86400000), false);
|
||||
});
|
||||
test('holiday timetable wins; missing holiday variant falls back to Sunday', async () => {
|
||||
const holidays = [{ date: '2026-10-09T00:00:00.000Z' }];
|
||||
const schedules = [schedule('weekday', [{ departureTime: '08:00' }]), schedule('sunday', [{ departureTime: '09:00' }])];
|
||||
const fallback = timetableDepartures(await bundle(schedules, { holidays }), '2026-10-09');
|
||||
assert.equal(fallback[0].scheduledTime, '09:15');
|
||||
const dedicated = timetableDepartures(await bundle([...schedules, schedule('holiday', [{ departureTime: '10:00' }])], { holidays }), '2026-10-09');
|
||||
assert.equal(dedicated[0].scheduledTime, '10:15');
|
||||
});
|
||||
test('trip restrictions and schedule validity apply to the actual service date', async () => {
|
||||
const data = await bundle([schedule('weekday', [{ departureTime: '08:00', daysOfWeek: [4] }, { departureTime: '09:00', exceptDaysOfWeek: [4] }], { valid_until: '2026-10-09' })]);
|
||||
assert.equal(timetableDepartures(data, '2026-10-08')[0].scheduledTime, '08:15');
|
||||
assert.equal(timetableDepartures(data, '2026-10-09')[0].scheduledTime, '09:15');
|
||||
assert.equal(timetableDepartures(data, '2026-10-12').length, 0);
|
||||
});
|
||||
test('previous service day retains after-midnight arrivals, with no time wrapping', async () => {
|
||||
const data = await bundle([schedule('weekday', [{ departureTime: '23:55', daysOfWeek: [4] }])]);
|
||||
const departures = timetableDepartures(data, '2026-10-09');
|
||||
assert.equal(departures.length, 1);
|
||||
assert.equal(departures[0].scheduledTime, '00:10');
|
||||
assert.equal(departures[0].scheduledAt, '2026-10-08T21:10:00.000Z');
|
||||
});
|
||||
test('partial and reverse trips use the start offset and correct terminal', async () => {
|
||||
const data = await bundle([schedule('weekday', [{ departureTime: '12:00', startStopId: 3, endStopId: 1, note: 'Обратно' }, { departureTime: '13:00', startStopId: 3, endStopId: 3 }])]);
|
||||
const departures = timetableDepartures(data, '2026-10-08').filter(item => localDate(Date.parse(item.scheduledAt)) === '2026-10-08');
|
||||
assert.equal(departures.length, 1); assert.equal(departures[0].scheduledTime, '12:15'); assert.equal(departures[0].destination, 'А');
|
||||
});
|
||||
test('failed calendar, route data, or malformed trips cannot publish a misleading complete cache', async () => {
|
||||
await assert.rejects(bundle([schedule('weekday', [{ departureTime: '12:00', startStopId: 999 }])]));
|
||||
await assert.rejects(bundle([schedule('weekday', [{ departureTime: '25:00' }])]));
|
||||
await assert.rejects(bundle([], { holidays: {} }));
|
||||
await assert.rejects(loadTimetable({ cityId: 1, cityName: 'Полоцк', stop: { id: 2, name: 'Б', routes: [route] }, now, upstream: async path => {
|
||||
if (path.endsWith('/schedules')) throw new Error('Upstream unavailable'); return path.includes('holidays') ? [] : stops;
|
||||
} }));
|
||||
});
|
||||
test('QR link identifies the exact city and platform, strips credentials and old query state', () => {
|
||||
assert.equal(passengerLink('https://pwa.ridebus.by/?demo=1', 1, 107), 'https://pwa.ridebus.by/?city=1&stop=107');
|
||||
assert.equal(passengerLink('javascript:alert(1)', 1, 1), 'https://pwa.ridebus.by/?city=1&stop=1');
|
||||
assert.equal(passengerLink('https://user:password@pwa.ridebus.by/#old', 2, 88), 'https://pwa.ridebus.by/?city=2&stop=88');
|
||||
});
|
||||
39
test/weather.test.mjs
Normal file
@@ -0,0 +1,39 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createWeatherClient } from '../server/weather.mjs';
|
||||
import { weatherAvailable, weatherCondition } from '../src/lib/weather.mjs';
|
||||
|
||||
const now = Date.parse('2026-10-08T12:00:00+03:00');
|
||||
const raw = { current: { time: now / 1000, temperature_2m: 12.2, apparent_temperature: 10.5, weather_code: 61, is_day: 1, wind_speed_10m: 3 },
|
||||
hourly: { time: [now / 1000 + 3600], temperature_2m: [13], weather_code: [3], precipitation_probability: [20] } };
|
||||
test('weather requests are coalesced and exclude transit credentials; Unix times retain Minsk instant', async () => {
|
||||
let calls = 0;
|
||||
const client = createWeatherClient({ now: () => now, fetchData: async (url, init) => {
|
||||
calls++; assert.equal(url.searchParams.get('wind_speed_unit'), 'ms'); assert.equal(url.searchParams.get('timeformat'), 'unixtime');
|
||||
assert.deepEqual(init.headers, { Accept: 'application/json' }); return Response.json(raw);
|
||||
} });
|
||||
const [first, second] = await Promise.all([client.weather(1, 1, { latitude: 55.5, longitude: 28.7 }), client.weather(1, 1, { latitude: 55.5, longitude: 28.7 })]);
|
||||
assert.deepEqual(first, second); assert.equal(calls, 1); assert.equal(first.observedAt, new Date(now).toISOString());
|
||||
assert.equal(weatherAvailable(first, now + 30 * 60000), true); assert.equal(weatherAvailable(first, now + 61 * 60000), false);
|
||||
assert.equal(weatherCondition(first.code).label, 'Дождь');
|
||||
});
|
||||
test('missing coordinates and null weather values never produce a fabricated 0 degree forecast', async () => {
|
||||
const client = createWeatherClient({ now: () => now, fetchData: async () => Response.json({ ...raw, current: { ...raw.current, temperature_2m: null } }) });
|
||||
await assert.rejects(client.weather(1, 1, { latitude: null, longitude: null }), error => error.code === 'coordinates_unavailable');
|
||||
await assert.rejects(client.weather(1, 1, { latitude: 55, longitude: 28 }), error => error.code === 'weather_unavailable');
|
||||
});
|
||||
test('commercial provider key selects its endpoint and is never exposed in response or errors', async () => {
|
||||
const client = createWeatherClient({ apiKey: 'private-weather-key', now: () => now, fetchData: async url => {
|
||||
assert.equal(url.host, 'customer-api.open-meteo.com'); assert.equal(url.searchParams.get('apikey'), 'private-weather-key'); return Response.json(raw);
|
||||
} });
|
||||
assert.ok(!JSON.stringify(await client.weather(1, 1, { latitude: 55, longitude: 28 })).includes('private-weather-key'));
|
||||
});
|
||||
test('provider outage backs off for a minute without extending the old forecast timestamp', async () => {
|
||||
let current = now, calls = 0;
|
||||
const client = createWeatherClient({ now: () => current, fetchData: async () => { calls++; return calls === 1 ? Response.json(raw) : new Response('', { status: 503 }); } });
|
||||
const coords = { latitude: 55, longitude: 28 };
|
||||
const saved = await client.weather(1, 1, coords);
|
||||
current += 11 * 60000;
|
||||
await assert.rejects(client.weather(1, 1, coords)); await assert.rejects(client.weather(1, 1, coords));
|
||||
assert.equal(calls, 2); assert.equal(saved.updatedAt, new Date(now).toISOString());
|
||||
});
|
||||
10
tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022", "lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext", "moduleResolution": "Bundler", "jsx": "react-jsx",
|
||||
"strict": true, "noEmit": true, "skipLibCheck": true,
|
||||
"allowJs": true, "esModuleInterop": true, "resolveJsonModule": true,
|
||||
"noUnusedLocals": true, "noUnusedParameters": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
8
vite.config.mjs
Normal file
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { shellCachePlugin } from './scripts/shell-cache.mjs';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), shellCachePlugin()],
|
||||
build: { target: 'es2020' },
|
||||
});
|
||||